corems.molecular_formula.calc.MolecularFormulaCalc

   1__author__ = "Yuri E. Corilo"
   2__date__ = "Jun 24, 2019"
   3
   4
   5import re
   6import warnings
   7
   8import IsoSpecPy
   9from numpy import array, exp, isnan, nextafter, power
  10
  11# this is to handle both versions of IsoSpecPy, 2.0.2 and 2.2.2
  12# TODO in a future release remove support for legacy isospecpy
  13from packaging import version
  14
  15from corems.encapsulation.constant import (
  16    ADDUCT_ALIASES as _ADDUCT_ALIASES,
  17    Atoms,
  18    ION_TYPE_DICT,
  19    Labels,
  20)
  21from corems.encapsulation.factory.parameters import MSParameters
  22from corems.molecular_id.calc.SpectralSimilarity import SpectralSimilarity
  23
  24isospec_version = IsoSpecPy.__version__
  25if version.parse(isospec_version) > version.parse("2.0.2"):
  26    legacy_isospec = False
  27else:
  28    legacy_isospec = True
  29if legacy_isospec:
  30    from IsoSpecPy import IsoSpecPy
  31
  32    warnings.warn(
  33        f"IsoSpecPy version {isospec_version} is installed, and support is deprecated. Please update to 2.2.2",
  34        DeprecationWarning,
  35    )
  36
  37
  38class MolecularFormulaCalc:
  39    """Class of calculations related to molecular formula
  40
  41    This class is not intended to be used directly, but rather to be inherited by other classes in the molecular_formula/factory module like MolecularFormula and MolecularFormulaIsotopologue
  42
  43    Attributes
  44    ----------
  45    mz_calc : float
  46        The m/z value of the molecular formula.
  47    neutral_mass : float
  48        The neutral mass of the molecular formula.
  49    ion_charge : int
  50        The ion charge of the molecular formula.
  51    _external_mz : float
  52        The externally provided m/z value of the molecular formula.
  53    _d_molecular_formula : dict
  54        The dictionary representation of the molecular formula.
  55    _mspeak_parent : object
  56        The parent MS peak object associated with the molecular formula.
  57    _assignment_mass_error : float
  58        The mass error of the molecular formula.
  59
  60    Methods
  61    -------
  62    * _calc_resolving_power_low_pressure(B, T)
  63        Calculate the resolving power at low pressure.
  64    * _calc_resolving_power_high_pressure(B, T)
  65        Calculate the resolving power at high pressure.
  66    * _adduct_mz(adduct_atom, ion_charge)
  67        Get the m/z value of an adducted ion version of the molecular formula.
  68    * _protonated_mz(ion_charge)
  69        Get the m/z value of a protonated or deprotonated ion version of the molecular formula.
  70    * _radical_mz(ion_charge)
  71        Get the m/z value of a radical ion version of the molecular formula.
  72    * normalize_ion_type(ion_type)
  73        Map alternate LCMS adduct labels onto ``ion_type_dict`` keys.
  74    * charge_from_adduct(adduct)
  75        Parse integer charge from an LCMS adduct string.
  76    * get_ion_formula(neutral_formula, ion_type)
  77        Build ion formula string from neutral formula and LCMS adduct / ion type.
  78    * precursor_mz_from_formula(neutral_formula, ion_type, charge=None)
  79        Calculate precursor m/z from neutral formula and LCMS adduct / ion type.
  80    * _neutral_mass()
  81        Get the neutral mass of the molecular formula.
  82    * _calc_mz()
  83        Get the m/z value of the molecular formula.
  84    * _calc_assignment_mass_error(method='ppm')
  85        Calculate the mass error of the molecular formula.
  86    * _calc_mz_confidence(mean=0)
  87        Calculate the m/z confidence of the molecular formula.
  88    * _calc_isotopologue_confidence()
  89        Calculate the isotopologue confidence of the molecular formula.
  90    * normalize_distance(dist, dist_range)
  91        Normalize the distance value.
  92    * subtract_formula(formula_obj, formated=True)
  93        Subtract a formula from the current formula object.
  94    * _calc_average_mz_score()
  95        Calculate the average m/z error score of the molecular formula identification, including the isotopologues.
  96
  97    Notes
  98    -----
  99    LCMS adduct helpers use :data:`~corems.encapsulation.constant.ION_TYPE_DICT`
 100    (``[M+H]+`` / ``[M-H]-``, etc.). Distinct from FTMS assignment labels in
 101    :class:`~corems.encapsulation.constant.Labels` (``DE_OR_PROTONATED``,
 102    ``RADICAL``, ``ADDUCT``) used by ``_calc_mz``.
 103    """
 104
 105    # Back-compat aliases → corems.encapsulation.constant source of truth
 106    ion_type_dict = ION_TYPE_DICT
 107    ADDUCT_ALIASES = _ADDUCT_ALIASES
 108
 109    def _calc_resolving_power_low_pressure(self, B, T):
 110        """
 111        Calculate the resolving power at low pressure.
 112
 113        Parameters
 114        ----------
 115        B : float
 116            Magnetic Strength (Testa).
 117        T : float
 118            Transient time (seconds).
 119
 120        """
 121        return (1.274 * 10000000 * B * T) * (1 / self.mz_calc)
 122
 123    def _calc_resolving_power_high_pressure(self, B, T):
 124        """
 125        Calculate the resolving power at high pressure.
 126
 127        Parameters
 128        ----------
 129        B : float
 130            Magnetic Strength (Testa).
 131        T : float
 132            Transient time (seconds).
 133
 134        """
 135        return (2.758 * 10000000 * B * T) * (1 / self.mz_calc)
 136
 137    def _adduct_mz(self, adduct_atom, ion_charge):
 138        """Get the m/z value of an adducted ion version of the molecular formula.
 139
 140        Parameters
 141        ----------
 142        adduct_atom : str
 143            The adduct atom.
 144        ion_charge : int
 145            The ion charge.
 146
 147        """
 148        return (
 149            self.neutral_mass
 150            + (Atoms.atomic_masses.get(adduct_atom))
 151            + (ion_charge * -1 * Atoms.electron_mass)
 152        ) / abs(ion_charge)
 153
 154    def _protonated_mz(self, ion_charge):
 155        """Get the m/z value of a protonated or deprotonated ion version of the molecular formula.
 156
 157        Parameters
 158        ----------
 159        ion_charge : int
 160            The ion charge.
 161        """
 162        return (
 163            self.neutral_mass
 164            + (ion_charge * Atoms.atomic_masses.get("H"))
 165            + (ion_charge * -1 * Atoms.electron_mass)
 166        ) / abs(ion_charge)
 167
 168    def _radical_mz(self, ion_charge):
 169        """Get the m/z value of a radical ion version of the molecular formula.
 170
 171        Parameters
 172        ----------
 173        ion_charge : int
 174            The ion charge.
 175        """
 176        return (self.neutral_mass + (ion_charge * -1 * Atoms.electron_mass)) / abs(
 177            ion_charge
 178        )
 179
 180    @staticmethod
 181    def normalize_ion_type(ion_type: str) -> str:
 182        """Map alternate LCMS adduct labels onto ``ion_type_dict`` keys."""
 183        return MolecularFormulaCalc.ADDUCT_ALIASES.get(ion_type, ion_type)
 184
 185    @staticmethod
 186    def charge_from_adduct(adduct: str) -> int:
 187        """Parse integer charge from an adduct string (e.g. ``[M+H]+``, ``[M+2H]2+``)."""
 188        a = (adduct or "").strip()
 189        m = re.search(r"(\d+)([+-])$", a)
 190        if m:
 191            return int(m.group(1)) * (1 if m.group(2) == "+" else -1)
 192        if a.endswith("+"):
 193            return 1
 194        if a.endswith("-"):
 195            return -1
 196        raise ValueError(f"cannot parse charge from adduct {adduct!r}")
 197
 198    @staticmethod
 199    def get_ion_formula(neutral_formula, ion_type):
 200        """From a neutral formula and an ion type, return the formula of the ion.
 201
 202        Parameters
 203        ----------
 204        neutral_formula : str
 205            Neutral formula as MolecularFormula-style (``'C2 H4 O2'``) or compact
 206            (``'C2H4O2'``; isotopes only handled in the spaced form).
 207        ion_type : str
 208            Ion type / adduct key (see ``ion_type_dict``), or a known alias.
 209            LCMS adduct notation (e.g. ``[M+H]+``), not FTMS ``Labels`` ion types.
 210
 211        Returns
 212        -------
 213        str or None
 214            Ion formula string (e.g. ``'C2 H5 O2'``), or None if
 215            ``neutral_formula`` is not a string.
 216        """
 217        # Lazy import avoids circular dependency: factory inherits this class.
 218        from corems.molecular_formula.factory.MolecularFormulaFactory import (
 219            MolecularFormula,
 220        )
 221
 222        if not isinstance(neutral_formula, str):
 223            return None
 224
 225        ion_type = MolecularFormulaCalc.normalize_ion_type(ion_type)
 226        if ion_type not in MolecularFormulaCalc.ion_type_dict:
 227            raise KeyError(f"unsupported ion type {ion_type!r}")
 228
 229        if re.search(r"\s", neutral_formula):
 230            formula_obj = MolecularFormula(neutral_formula, ion_charge=0)
 231        else:
 232            form_pre = re.sub(r"([A-Z])", r" \1", neutral_formula)[1:]
 233            elements = [re.findall(r"[A-Z][a-z]*", x) for x in form_pre.split()]
 234            counts = [re.findall(r"\d+", x) for x in form_pre.split()]
 235            formula_obj = MolecularFormula(
 236                dict(
 237                    zip(
 238                        [x[0] for x in elements],
 239                        [int(x[0]) if x else 1 for x in counts],
 240                    )
 241                ),
 242                ion_charge=0,
 243            )
 244        neutral_formula_dict = formula_obj.to_dict().copy()
 245
 246        entry = MolecularFormulaCalc.ion_type_dict[ion_type]
 247        adduct_add_dict = entry["add"]
 248        for key in adduct_add_dict:
 249            if key in neutral_formula_dict:
 250                neutral_formula_dict[key] += adduct_add_dict[key]
 251            else:
 252                neutral_formula_dict[key] = adduct_add_dict[key]
 253
 254        adduct_subtract = entry["sub"]
 255        for key in adduct_subtract:
 256            neutral_formula_dict[key] -= adduct_subtract[key]
 257
 258        return MolecularFormula(neutral_formula_dict, ion_charge=0).string
 259
 260    @staticmethod
 261    def polarity_from_ion_type(ion_type: str) -> str:
 262        """Return ``'positive'`` or ``'negative'`` from ``ION_TYPE_DICT``."""
 263        ion_type = MolecularFormulaCalc.normalize_ion_type(ion_type)
 264        if ion_type not in ION_TYPE_DICT:
 265            raise KeyError(f"unsupported ion type {ion_type!r}")
 266        return ION_TYPE_DICT[ion_type]["polarity"]
 267
 268    @staticmethod
 269    def precursor_mz_from_formula(neutral_formula, ion_type, charge=None):
 270        """Calculated precursor m/z from neutral formula and adduct / ion type.
 271
 272        Parameters
 273        ----------
 274        neutral_formula : str
 275            Neutral molecular formula.
 276        ion_type : str
 277            Adduct or ion type (``ion_type_dict`` key or alias).
 278            LCMS adduct notation (e.g. ``[M-H]-``), not FTMS ``Labels`` ion types.
 279        charge : int, optional
 280            Ion charge. If omitted, inferred from ``ion_type``.
 281
 282        Returns
 283        -------
 284        float
 285            Precursor m/z of the ion.
 286
 287        Raises
 288        ------
 289        KeyError
 290            If ``ion_type`` is not supported.
 291        ValueError
 292            If the ion formula cannot be built or charge cannot be inferred.
 293        """
 294        # Lazy import avoids circular dependency: factory inherits this class.
 295        from corems.molecular_formula.factory.MolecularFormulaFactory import (
 296            MolecularFormula,
 297        )
 298
 299        ion_form = MolecularFormulaCalc.get_ion_formula(neutral_formula, ion_type)
 300        if ion_form is None:
 301            raise ValueError(
 302                f"could not build ion formula for {neutral_formula!r} {ion_type!r}"
 303            )
 304        if charge is None:
 305            charge = MolecularFormulaCalc.charge_from_adduct(
 306                MolecularFormulaCalc.normalize_ion_type(ion_type)
 307            )
 308        if charge == 0:
 309            raise ValueError("charge must be non-zero for precursor m/z")
 310        mf = MolecularFormula(ion_form, ion_charge=0)
 311        return float(
 312            (mf.neutral_mass + (charge * -1 * Atoms.electron_mass)) / abs(charge)
 313        )
 314
 315    def _neutral_mass(self):
 316        """Get the neutral mass of the molecular formula."""
 317
 318        mass = 0
 319
 320        for each_atom in self._d_molecular_formula.keys():
 321            if each_atom != Labels.ion_type and each_atom != "HC":
 322                try:
 323                    mass = mass + Atoms.atomic_masses[
 324                        each_atom
 325                    ] * self._d_molecular_formula.get(each_atom)
 326
 327                except:
 328                    print(Labels.ion_type, each_atom)
 329
 330        return mass
 331
 332    def _calc_mz(self):
 333        """Get the m/z value of the molecular formula, based on the ion charge and ion type."""
 334
 335        if self.ion_charge is not None:
 336            if self._external_mz:
 337                return self._external_mz
 338
 339            else:
 340                ion_type = self._d_molecular_formula.get(Labels.ion_type)
 341
 342                if ion_type == Labels.protonated_de_ion:
 343                    return self.protonated_mz
 344
 345                elif ion_type == Labels.radical_ion or ion_type == Labels.adduct_ion:
 346                    return self.radical_mz
 347
 348                elif ion_type == Labels.neutral:
 349                    return self.neutral_mass
 350
 351                elif self.ion_charge == 0:
 352                    return self.neutral_mass
 353
 354                else:
 355                    # formula is probably ion form used for bruker ref list
 356                    return self.neutral_mass
 357
 358        else:
 359            raise Exception("Please set ion charge first")
 360
 361    def _calc_assignment_mass_error(self, method="ppm"):
 362        """Calculate the mass error of the molecular formula, based on the experimental m/z and the calculated m/z.
 363
 364        Parameters
 365        ----------
 366        method : str, optional
 367            The method to calculate the mass error, by default 'ppm', but can be 'ppb'
 368
 369        Raises
 370        ------
 371        Exception
 372            If the method is not 'ppm' or 'ppb'.
 373        Exception
 374            If there is no ms peak associated with the molecular formula instance.
 375        """
 376
 377        if method == "ppm":
 378            multi_factor = 1000000
 379
 380        elif method == "ppb":
 381            multi_factor = 1000000
 382
 383        else:
 384            raise Exception(
 385                "method needs to be ppm or ppb, you have entered %s" % method
 386            )
 387
 388        if self._mspeak_parent.mz_exp:
 389            self._assignment_mass_error = (
 390                (self._mspeak_parent.mz_exp - self.mz_calc) / self.mz_calc
 391            ) * multi_factor
 392
 393            return (
 394                (self._mspeak_parent.mz_exp - self.mz_calc) / self.mz_calc
 395            ) * multi_factor
 396
 397        else:
 398            raise Exception(
 399                "No ms peak associated with the molecular formula instance %s", self
 400            )
 401
 402    def _calc_mz_confidence(self, mean=0):
 403        """Calculate the monoisotopic peak Gaussian mass-accuracy score.
 404
 405        Implementation for the public :attr:`mz_error_score` property.
 406        See that property and the ``corems.molecular_formula`` package
 407        documentation for the equation, ``predicted_std`` / 1.66 ppm
 408        fallback, and interpretation.
 409
 410        Parameters
 411        ----------
 412        mean : float, optional
 413            Mean mass error (ppm) for the Gaussian; default 0 (calibrated
 414            spectrum with near-zero mean error).
 415        """
 416
 417        # predicted_std not set: fallback width (ppm) for the Gaussian score
 418        if not self._mspeak_parent.predicted_std:
 419            self._mspeak_parent.predicted_std = 1.66
 420
 421        return exp(
 422            -1
 423            * (
 424                power((self.mz_error - mean), 2)
 425                / (2 * power(self._mspeak_parent.predicted_std, 2))
 426            )
 427        )
 428
 429    def _calc_isotopologue_confidence(self):
 430        """Calculate isotopologue abundance pattern similarity.
 431
 432        Implementation for the public :attr:`isotopologue_similarity`
 433        property. See that property and the ``corems.molecular_formula``
 434        package documentation for interpretation.
 435
 436        Returns
 437        -------
 438        float
 439            Similarity in about [0, 1], or 0.0 when no isotopologues are
 440            expected.
 441        """
 442
 443        if self.is_isotopologue:
 444            # confidence of isotopologue is pure mz error
 445            # TODO add more features here
 446
 447            mformula_index = self.mono_isotopic_formula_index
 448            mspeak_index = self.mspeak_index_mono_isotopic
 449            parent_ms = self._mspeak_parent._ms_parent
 450            if (
 451                mformula_index is None
 452                or mspeak_index is None
 453                or mspeak_index < 0
 454                or mspeak_index >= len(parent_ms)
 455            ):
 456                return 0.0
 457            mspeak = parent_ms[mspeak_index]
 458            if mformula_index < 0 or mformula_index >= len(mspeak):
 459                # Parent formula list was filtered (e.g. ion-type constrain);
 460                # stale isotopologue indexes should not fail export.
 461                return 0.0
 462
 463            expected_isotopologues = mspeak[mformula_index].expected_isotopologues
 464
 465            mono_mz = mspeak[mformula_index].mz_calc
 466            mono_abundance = mspeak.abundance
 467
 468        else:
 469            mono_mz = self.mz_calc
 470            mono_abundance = self._mspeak_parent.abundance
 471
 472            expected_isotopologues = self.expected_isotopologues
 473            # has isotopologues based on current dinamic range
 474
 475        if expected_isotopologues:
 476            dict_mz_abund_ref = {"mz": [mono_mz], "abundance": [mono_abundance]}
 477
 478            # get reference data
 479            for mf in expected_isotopologues:
 480                dict_mz_abund_ref["abundance"].append(mf.abundance_calc)
 481                dict_mz_abund_ref["mz"].append(mf.mz_calc)
 482
 483            dict_mz_abund_exp = {mono_mz: mono_abundance}
 484
 485            # get experimental data
 486            for mf in expected_isotopologues:
 487                # molecular formula has been assigned to a peak
 488                if mf._mspeak_parent:
 489                    # stores mspeak abundance
 490                    dict_mz_abund_exp[mf.mz_calc] = mf._mspeak_parent.abundance
 491
 492                else:
 493                    # fill missing mz with abundance 0 and mz error score of 0
 494                    dict_mz_abund_exp[mf.mz_calc] = nextafter(0, 1)
 495
 496            distance = SpectralSimilarity(
 497                dict_mz_abund_exp, dict_mz_abund_ref
 498            ).manhattan_distance()
 499            correlation = 1 - self.normalize_distance(distance, [0, 2])
 500            # correlation = dwt_correlation(dict_mz_abund_exp, dict_mz_abund_ref)
 501            # correlation = cosine_correlation(dict_mz_abund_exp, dict_mz_abund_ref)
 502
 503            if isnan(correlation):
 504                correlation = 0.00001
 505
 506        else:
 507            # no isotopologue expected giving a correlation score of 0.0 but it needs optimization
 508            correlation = 0.0
 509
 510        return correlation
 511
 512    def normalize_distance(self, dist, dist_range):
 513        """
 514        Normalize the distance value.
 515
 516        Parameters
 517        ----------
 518        dist : float
 519            The distance value to be normalized.
 520        dist_range : list
 521            The range of the distance value.
 522
 523        """
 524        result = (dist - dist_range[0]) / (dist_range[1] - dist_range[0])
 525
 526        if result < 0:
 527            result = 0.0
 528        elif result > 1:
 529            result = 1.0
 530
 531        return result
 532
 533    def subtract_formula(self, formula_obj, formated=True):
 534        """Subtract a formula from the current formula object
 535
 536        Parameters
 537        ----------
 538        formula_obj : MolecularFormula
 539            MolecularFormula object to be subtracted from the current formula object
 540        formated : bool, optional
 541            If True, returns the formula in string format, by default True
 542
 543        """
 544        subtraction = {}
 545        for atom, value in self.to_dict().items():
 546            if atom != Labels.ion_type:
 547                if formula_obj.get(atom):
 548                    # value_subtraction = value - formula_obj.get(atom)
 549                    if value - formula_obj.get(atom) > 0:
 550                        subtraction[atom] = value - formula_obj.get(atom)
 551                else:
 552                    subtraction[atom] = value
 553        if formated:
 554            SUB = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
 555            SUP = str.maketrans("0123456789", "⁰¹²³⁴⁵⁶⁷⁸⁹")
 556        else:
 557            SUB = str.maketrans("0123456789", "0123456789")
 558            SUP = str.maketrans("0123456789", "0123456789")
 559        formula_srt = ""
 560        for atom in Atoms.atoms_order:
 561            if atom in subtraction.keys():
 562                formula_srt += atom.translate(SUP) + str(
 563                    int(subtraction.get(atom))
 564                ).translate(SUB)
 565
 566        return formula_srt
 567
 568    def _calc_average_mz_score(self):
 569        """Average mass-accuracy score over mono and expected isotopologues.
 570
 571        Implementation for the public :attr:`average_mz_error_score`
 572        property. See that property and the ``corems.molecular_formula``
 573        package documentation for interpretation.
 574        """
 575        if self.is_isotopologue:
 576            # confidence of isotopologue is pure mz error
 577            # TODO add more features here
 578
 579            mformula_index = self.mono_isotopic_formula_index
 580            mspeak_index = self.mspeak_index_mono_isotopic
 581
 582            mspeak = self._mspeak_parent._ms_parent[mspeak_index]
 583
 584            expected_isotopologues = mspeak[mformula_index].expected_isotopologues
 585
 586        else:
 587            expected_isotopologues = self.expected_isotopologues
 588            # has isotopologues based on current dinamic range
 589
 590        accumulated_mz_score = [self.mz_error_score]
 591
 592        if expected_isotopologues:
 593            for mf in expected_isotopologues:
 594                # molecular formula has been assigned to a peak
 595                if mf._mspeak_parent:
 596                    # stores mspeak abundance
 597                    accumulated_mz_score.append(mf.mz_error_score)
 598                else:
 599                    # fill missing mz with abundance 0 and mz error score of 0
 600                    accumulated_mz_score.append(0.0)
 601
 602        average_mz_score = sum(accumulated_mz_score) / len(accumulated_mz_score)
 603
 604        if isnan(average_mz_score):
 605            average_mz_score = 0.0
 606
 607        return average_mz_score
 608
 609    def _calc_confidence_score(self):
 610        """Composite confidence score from mass error and isotopologues.
 611
 612        Implementation for the public :attr:`confidence_score` property:
 613
 614        ```
 615        CS = (w_err * average_mz_error_score) + (w_iso * isotopologue_similarity)
 616        ```
 617
 618        with weights from the parent spectrum's molecular search settings.
 619        Assumes a calibrated spectrum with near-zero mean mass error.
 620        See the public property and the ``corems.molecular_formula`` package
 621        documentation for the full formulation and literature reference.
 622        """
 623
 624        isotopologue_correlation = self.isotopologue_similarity
 625        average_mz_score = self.average_mz_error_score
 626        a = self._mspeak_parent._ms_parent.molecular_search_settings.mz_error_score_weight
 627        b = self._mspeak_parent._ms_parent.molecular_search_settings.isotopologue_score_weight
 628
 629        score = (isotopologue_correlation * b) + (average_mz_score * a)
 630
 631        return score
 632
 633    def _calc_abundance_error(self, method="percentile"):
 634        """Calculate the abundance error of the molecular formula, based on the experimental abundance and the calculated abundance.
 635
 636        Parameters
 637        ----------
 638        method : str, optional
 639            The method to calculate the abundance error, by default 'percentile', but can be 'ppm' or 'ppb'
 640
 641        Returns
 642        -------
 643        float
 644            The abundance error of the molecular formula.
 645
 646        Raises
 647        ------
 648        Exception
 649            If isotopologues were not calculated.
 650        """
 651
 652        mult_factor = 100
 653
 654        iso_abundance = self._mspeak_parent.abundance
 655        mono_abundance = self._mspeak_parent._ms_parent[
 656            self.mspeak_index_mono_isotopic
 657        ].abundance
 658
 659        if self.prob_ratio:
 660            theor_abundance = mono_abundance * self.prob_ratio
 661            # self.parent need to have a MassSpecPeak associated with the MolecularFormula class
 662            return ((theor_abundance - iso_abundance) / theor_abundance) * mult_factor
 663
 664        else:
 665            raise Exception("Please calc_isotopologues")
 666
 667    def _calc_area_error(self, method="percentile"):
 668        """Calculate the area error of the molecular formula, based on the experimental area and the calculated area.
 669
 670        Parameters
 671        ----------
 672        method : str, optional
 673            The method to calculate the area error, by default 'percentile', but can be 'ppm' or 'ppb'
 674
 675        Returns
 676        -------
 677        float
 678            The area error of the molecular formula.
 679
 680        Raises
 681        ------
 682        Exception
 683            If isotopologues were not calculated.
 684        """
 685
 686        mult_factor = 100
 687
 688        iso_area = self._mspeak_parent.area
 689        mono_area = self._mspeak_parent._ms_parent[self.mspeak_index_mono_isotopic].area
 690
 691        if self.prob_ratio:
 692            if mono_area and iso_area:
 693                # exp_ratio = iso_area/mono_area
 694
 695                area_calc = mono_area * self.prob_ratio
 696
 697                # self.parent need to have a MassSpecPeak associated with the MolecularFormula class
 698                return ((area_calc - iso_area) / area_calc) * mult_factor
 699                # return ((self.prob_ratio - exp_ratio )/self.prob_ratio)*mult_factor
 700
 701            else:
 702                # centroid mass spectrum
 703                return 0
 704        else:
 705            raise Exception("Please calc_isotopologues")
 706
 707    def _calc_aromaticity_index_mod(self):
 708        """Calculate the modified aromaticity index of the molecular formula.
 709
 710        Returns
 711        -------
 712        float
 713            The aromaticity index of the molecular formula.
 714
 715        Notes
 716        -----
 717        Source Koch and Dittmar, 2006 https://doi.org/10.1002/rcm.2386
 718        corrected in https://doi.org/10.1002/rcm.7433
 719        """
 720        # Prepare empty dictionary to store the number of atoms of each element
 721        ai_es = {"C": 0, "H": 0, "O": 0, "N": 0, "S": 0}
 722
 723        # Count the number of atoms of each element in the molecular formula, inclusive of isotopes
 724        for element in ai_es:
 725            elements_w_iso = [element] + Atoms.isotopes.get(element)[1]
 726            for element_w_iso in elements_w_iso:
 727                if element_w_iso in self._d_molecular_formula:
 728                    ai_es[element] += self._d_molecular_formula[element_w_iso]
 729
 730        ai_n = (
 731            1
 732            + ai_es["C"]
 733            - (0.5 * ai_es["O"])
 734            - ai_es["S"]
 735            - (0.5 * (ai_es["N"] + ai_es["H"]))
 736        )
 737        ai_d = ai_es["C"] - (0.5 * ai_es["O"]) - ai_es["N"] - ai_es["S"]
 738
 739        ai = ai_n / ai_d
 740
 741        if ai < 0:
 742            ai = 0
 743        if ai > 1:
 744            ai = 1
 745
 746        return ai
 747
 748    def _calc_aromaticity_index(self):
 749        """Calculate the aromaticity index of the molecular formula.
 750
 751        Returns
 752        -------
 753        float
 754            The aromaticity index of the molecular formula.
 755
 756        Notes
 757        -----
 758        Source Koch and Dittmar, 2006 https://doi.org/10.1002/rcm.2386
 759        corrected in https://doi.org/10.1002/rcm.7433
 760        """
 761        # Prepare empty dictionary to store the number of atoms of each element
 762        ai_es = {"C": 0, "H": 0, "O": 0, "N": 0, "S": 0}
 763
 764        # Count the number of atoms of each element in the molecular formula, inclusive of isotopes
 765        for element in ai_es:
 766            elements_w_iso = [element] + Atoms.isotopes.get(element)[1]
 767            for element_w_iso in elements_w_iso:
 768                if element_w_iso in self._d_molecular_formula:
 769                    ai_es[element] += self._d_molecular_formula[element_w_iso]
 770
 771                ai_n = (
 772                    1
 773                    + ai_es["C"]
 774                    - (ai_es["O"])
 775                    - ai_es["S"]
 776                    - (0.5 * (ai_es["N"] + ai_es["H"]))
 777                )
 778        ai_d = ai_es["C"] - (ai_es["O"]) - ai_es["N"] - ai_es["S"]
 779
 780        ai = ai_n / ai_d
 781
 782        if ai < 0:
 783            ai = 0
 784        if ai > 1:
 785            ai = 1
 786
 787        return ai
 788
 789    def _calc_nosc(self):
 790        """Calculate the average nominal oxidation state of carbon
 791
 792        Returns
 793        -------
 794        float
 795            The average nominal oxidation state of carbon
 796
 797        Notes
 798        -----
 799        Source LaRowe and Van Cappellen, 2011 https://doi.org/10.1016/j.gca.2011.01.020
 800        """
 801        # Prepare empty dictionary to store the number of atoms of each element
 802        nosc_es = {"C": 0, "H": 0, "O": 0, "N": 0, "S": 0, "P": 0}
 803
 804        # Count the number of atoms of each element in the molecular formula, inclusive of isotopes
 805        for element in nosc_es:
 806            elements_w_iso = [element] + Atoms.isotopes.get(element)[1]
 807            for element_w_iso in elements_w_iso:
 808                if element_w_iso in self._d_molecular_formula:
 809                    nosc_es[element] += self._d_molecular_formula[element_w_iso]
 810
 811        nosc = (
 812            -(
 813                (
 814                    4 * nosc_es["C"]
 815                    + nosc_es["H"]
 816                    - 3 * nosc_es["N"]
 817                    - 2 * nosc_es["O"]
 818                    + 5 * nosc_es["P"]
 819                    - 2 * nosc_es["S"]
 820                )
 821                / nosc_es["C"]
 822            )
 823            + 4
 824        )
 825
 826        # If nosc is infinite or negative infinity, set it to nan
 827        if nosc == float("inf") or nosc == float("-inf"):
 828            nosc = float("nan")
 829
 830        return nosc
 831
 832    @property
 833    def dbe_ai(self):
 834        """Calculate the double bond equivalent (DBE) of the molecular formula, based on the number of carbons, hydrogens, and oxygens."""
 835
 836        carbons = self._d_molecular_formula.get("C")
 837        hydrogens = self._d_molecular_formula.get("H")
 838        oxygens = self._d_molecular_formula.get("O")
 839        return 1 + (((2 * carbons) - hydrogens - (2 * oxygens)) * 0.5)
 840
 841    def _calc_dbe(self):
 842        """Calculate the double bond equivalent (DBE) of the molecular formula"""
 843
 844        individual_dbe = 0
 845
 846        for atom in self._d_molecular_formula.keys():
 847            if atom != Labels.ion_type:
 848                n_atom = int(self._d_molecular_formula.get(atom))
 849
 850                clean_atom = "".join([i for i in atom if not i.isdigit()])
 851
 852                if self._mspeak_parent:
 853                    valencia = self._mspeak_parent._ms_parent.molecular_search_settings.used_atom_valences.get(
 854                        clean_atom
 855                    )
 856                else:
 857                    valencia = MSParameters.molecular_search.used_atom_valences.get(
 858                        clean_atom
 859                    )
 860                # valencia = Atoms.atoms_covalence.get(atom)
 861
 862                if type(valencia) is tuple:
 863                    valencia = valencia[0]
 864                if valencia > 0:
 865                    # print atom, valencia, n_atom, individual_dbe
 866                    individual_dbe = individual_dbe + (n_atom * (valencia - 2))
 867                else:
 868                    continue
 869
 870        dbe = 1 + (0.5 * individual_dbe)
 871
 872        if self.ion_type == Labels.adduct_ion:
 873            dbe = dbe + 0.5
 874
 875        return dbe
 876
 877    def _calc_kmd(self, dict_base):
 878        """Calculate the Kendrick mass defect (KMD) of the molecular formula, based on the monoisotopic mass and the Kendrick mass.
 879
 880        Parameters
 881        ----------
 882        dict_base : dict
 883            The dictionary of the base formula, e.g. {'C':1, 'H':2}
 884
 885        Returns
 886        -------
 887        tuple
 888            The tuple of the KMD, Kendrick mass, and nominal Kendrick mass.
 889        """
 890        mass = 0
 891        for atom in dict_base.keys():
 892            mass = mass + Atoms.atomic_masses.get(atom) * dict_base.get(atom)
 893
 894        kendrick_mass = (int(mass) / mass) * self.mz_calc
 895
 896        nominal_km = int(kendrick_mass)
 897
 898        kmd = (nominal_km - kendrick_mass) * 100
 899
 900        # kmd = (nominal_km - km) * 1
 901        kmd = round(kmd, 0)
 902
 903        return kmd, kendrick_mass, nominal_km
 904
 905    def _cal_isotopologues(
 906        self, formula_dict, min_abundance, current_abundance, ms_dynamic_range
 907    ):
 908        """Calculate the isotopologues for a given molecular formula.
 909
 910        Parameters
 911        ----------
 912        formula_dict : dict
 913            The dictionary of the molecular formula. Example: {'C':10, 'H', 20, 'O', 2}
 914        min_abundance : float
 915            The minimum abundance.
 916        current_abundance : float
 917            The current monoisotopic abundance.
 918        ms_dynamic_range : float
 919            The dynamic range.
 920
 921
 922        Notes
 923        -----
 924        This is the primary function to look for isotopologues based on a monoisotopic molecular formula.
 925        It needs to be expanded to include the calculation of resolving power and plot the results.
 926        Use this function at runtime during the molecular identification algorithm only when a positive ID is observed to the monoisotopic ion.
 927        Use this function to simulate mass spectrum (needs resolving power calculation to be fully operational).
 928        It might break when adding non-conventional atoms (not yet tested).
 929        This function employs the IsoSpecPy library https://github.com/MatteoLacki/IsoSpec.
 930
 931
 932        """
 933
 934        # last update on 05-26-2020, Yuri E. Corilo
 935
 936        # updated it to reflect min possible mass peak abundance
 937        cut_off_to_IsoSpeccPy = 1 - (1 / ms_dynamic_range)
 938
 939        # print("cut_off_to_IsoSpeccPy", cut_off_to_IsoSpeccPy, current_abundance, min_abundance, ms_dynamic_range)
 940        # print(cut_off_to_IsoSpeccPy)
 941        atoms_labels = (
 942            atom
 943            for atom in formula_dict.keys()
 944            if atom != Labels.ion_type and atom != "H"
 945        )
 946
 947        atoms_count = []
 948        masses_list_tuples = []
 949        props_list_tuples = []
 950        all_atoms_list = []
 951
 952        for atom_label in atoms_labels:
 953            if Atoms.isotopes.get(atom_label)[1][0] is None:
 954                "This atom_label has no heavy isotope"
 955                atoms_count.append(formula_dict.get(atom_label))
 956                mass = Atoms.atomic_masses.get(atom_label)
 957                prop = Atoms.isotopic_abundance.get(atom_label)
 958                masses_list_tuples.append([mass])
 959                props_list_tuples.append([prop])
 960                all_atoms_list.append(atom_label)
 961
 962            else:
 963                isotopes_label_list = Atoms.isotopes.get(atom_label)[1]
 964
 965                if len(isotopes_label_list) > 1:
 966                    "This atom_label has two or more heavy isotope"
 967                    isotopos_labels = [i for i in isotopes_label_list]
 968                else:
 969                    "This atom_label only has one heavy isotope"
 970                    isotopos_labels = [isotopes_label_list[0]]
 971
 972                # all_atoms_list.extend(isotopos_labels)
 973                isotopos_labels = [atom_label] + isotopos_labels
 974
 975                all_atoms_list.extend(isotopos_labels)
 976
 977                masses = [
 978                    Atoms.atomic_masses.get(atom_label)
 979                    for atom_label in isotopos_labels
 980                ]
 981                props = [
 982                    Atoms.isotopic_abundance.get(atom_label)
 983                    for atom_label in isotopos_labels
 984                ]
 985
 986                atoms_count.append(formula_dict.get(atom_label))
 987                masses_list_tuples.append(masses)
 988                props_list_tuples.append(props)
 989        if legacy_isospec:
 990            iso = IsoSpecPy.IsoSpec(
 991                atoms_count,
 992                masses_list_tuples,
 993                props_list_tuples,
 994                cut_off_to_IsoSpeccPy,
 995            )
 996            conf = iso.getConfs()
 997            masses = conf[0]
 998            probs = exp(conf[1])
 999            molecular_formulas = conf[2]
1000            # print('conf', conf)
1001            # print('probs', conf[1])
1002        else:
1003            # This syntax in IsoSpecPy 2.2.2 yields the same information as the legacy approach
1004            iso = IsoSpecPy.IsoTotalProb(
1005                atomCounts=atoms_count,
1006                isotopeMasses=masses_list_tuples,
1007                isotopeProbabilities=props_list_tuples,
1008                prob_to_cover=cut_off_to_IsoSpeccPy,
1009                get_confs=True,
1010            )
1011            masses = list(iso.masses)
1012            probs = array(list(iso.probs))
1013            confs = list(iso.confs)
1014
1015            molecular_formulas = []
1016            for x in confs:
1017                tmplist = []
1018                for y in x:
1019                    tmplist.extend(list(y))
1020                molecular_formulas.append(tmplist)
1021
1022        new_formulas = []
1023
1024        for isotopologue_index in range(len(iso)):
1025            # skip_mono_isotopic
1026
1027            formula_list = molecular_formulas[isotopologue_index]
1028            new_formula_dict = dict(zip(all_atoms_list, formula_list))
1029            new_formula_dict[Labels.ion_type] = formula_dict.get(Labels.ion_type)
1030            if formula_dict.get("H"):
1031                new_formula_dict["H"] = formula_dict.get("H")
1032
1033            new_formulas.append({x: y for x, y in new_formula_dict.items() if y != 0})
1034
1035        # formula_dict in new_formulas check if monoisotopic is being returned
1036        if new_formulas:  # and formula_dict in new_formulas:
1037            # print(conf)
1038            # print(new_formulas)
1039            # print(atoms_count)
1040            # print(all_atoms_list)
1041            # print(masses_list_tuples)
1042            # print(props_list_tuples)
1043            # find where monoisotopic is
1044            index_mono = new_formulas.index(formula_dict)
1045            # calculate ratio iso/mono
1046            probs = list(probs / probs[index_mono])
1047
1048            # delete the monoisotopic
1049            del probs[index_mono]
1050            del new_formulas[index_mono]
1051
1052            # print('probs_exp', probs)
1053            for formulas, prob in zip(new_formulas, probs):
1054                theor_abundance = current_abundance * prob
1055                if theor_abundance > min_abundance:
1056                    # print(prob, theor_abundance, current_abundance)
1057                    yield (formulas, prob)
1058            # return zip(new_formulas, probs )
1059
1060        # else:
1061        #    return []
isospec_version = '2.3.5'
class MolecularFormulaCalc:
  39class MolecularFormulaCalc:
  40    """Class of calculations related to molecular formula
  41
  42    This class is not intended to be used directly, but rather to be inherited by other classes in the molecular_formula/factory module like MolecularFormula and MolecularFormulaIsotopologue
  43
  44    Attributes
  45    ----------
  46    mz_calc : float
  47        The m/z value of the molecular formula.
  48    neutral_mass : float
  49        The neutral mass of the molecular formula.
  50    ion_charge : int
  51        The ion charge of the molecular formula.
  52    _external_mz : float
  53        The externally provided m/z value of the molecular formula.
  54    _d_molecular_formula : dict
  55        The dictionary representation of the molecular formula.
  56    _mspeak_parent : object
  57        The parent MS peak object associated with the molecular formula.
  58    _assignment_mass_error : float
  59        The mass error of the molecular formula.
  60
  61    Methods
  62    -------
  63    * _calc_resolving_power_low_pressure(B, T)
  64        Calculate the resolving power at low pressure.
  65    * _calc_resolving_power_high_pressure(B, T)
  66        Calculate the resolving power at high pressure.
  67    * _adduct_mz(adduct_atom, ion_charge)
  68        Get the m/z value of an adducted ion version of the molecular formula.
  69    * _protonated_mz(ion_charge)
  70        Get the m/z value of a protonated or deprotonated ion version of the molecular formula.
  71    * _radical_mz(ion_charge)
  72        Get the m/z value of a radical ion version of the molecular formula.
  73    * normalize_ion_type(ion_type)
  74        Map alternate LCMS adduct labels onto ``ion_type_dict`` keys.
  75    * charge_from_adduct(adduct)
  76        Parse integer charge from an LCMS adduct string.
  77    * get_ion_formula(neutral_formula, ion_type)
  78        Build ion formula string from neutral formula and LCMS adduct / ion type.
  79    * precursor_mz_from_formula(neutral_formula, ion_type, charge=None)
  80        Calculate precursor m/z from neutral formula and LCMS adduct / ion type.
  81    * _neutral_mass()
  82        Get the neutral mass of the molecular formula.
  83    * _calc_mz()
  84        Get the m/z value of the molecular formula.
  85    * _calc_assignment_mass_error(method='ppm')
  86        Calculate the mass error of the molecular formula.
  87    * _calc_mz_confidence(mean=0)
  88        Calculate the m/z confidence of the molecular formula.
  89    * _calc_isotopologue_confidence()
  90        Calculate the isotopologue confidence of the molecular formula.
  91    * normalize_distance(dist, dist_range)
  92        Normalize the distance value.
  93    * subtract_formula(formula_obj, formated=True)
  94        Subtract a formula from the current formula object.
  95    * _calc_average_mz_score()
  96        Calculate the average m/z error score of the molecular formula identification, including the isotopologues.
  97
  98    Notes
  99    -----
 100    LCMS adduct helpers use :data:`~corems.encapsulation.constant.ION_TYPE_DICT`
 101    (``[M+H]+`` / ``[M-H]-``, etc.). Distinct from FTMS assignment labels in
 102    :class:`~corems.encapsulation.constant.Labels` (``DE_OR_PROTONATED``,
 103    ``RADICAL``, ``ADDUCT``) used by ``_calc_mz``.
 104    """
 105
 106    # Back-compat aliases → corems.encapsulation.constant source of truth
 107    ion_type_dict = ION_TYPE_DICT
 108    ADDUCT_ALIASES = _ADDUCT_ALIASES
 109
 110    def _calc_resolving_power_low_pressure(self, B, T):
 111        """
 112        Calculate the resolving power at low pressure.
 113
 114        Parameters
 115        ----------
 116        B : float
 117            Magnetic Strength (Testa).
 118        T : float
 119            Transient time (seconds).
 120
 121        """
 122        return (1.274 * 10000000 * B * T) * (1 / self.mz_calc)
 123
 124    def _calc_resolving_power_high_pressure(self, B, T):
 125        """
 126        Calculate the resolving power at high pressure.
 127
 128        Parameters
 129        ----------
 130        B : float
 131            Magnetic Strength (Testa).
 132        T : float
 133            Transient time (seconds).
 134
 135        """
 136        return (2.758 * 10000000 * B * T) * (1 / self.mz_calc)
 137
 138    def _adduct_mz(self, adduct_atom, ion_charge):
 139        """Get the m/z value of an adducted ion version of the molecular formula.
 140
 141        Parameters
 142        ----------
 143        adduct_atom : str
 144            The adduct atom.
 145        ion_charge : int
 146            The ion charge.
 147
 148        """
 149        return (
 150            self.neutral_mass
 151            + (Atoms.atomic_masses.get(adduct_atom))
 152            + (ion_charge * -1 * Atoms.electron_mass)
 153        ) / abs(ion_charge)
 154
 155    def _protonated_mz(self, ion_charge):
 156        """Get the m/z value of a protonated or deprotonated ion version of the molecular formula.
 157
 158        Parameters
 159        ----------
 160        ion_charge : int
 161            The ion charge.
 162        """
 163        return (
 164            self.neutral_mass
 165            + (ion_charge * Atoms.atomic_masses.get("H"))
 166            + (ion_charge * -1 * Atoms.electron_mass)
 167        ) / abs(ion_charge)
 168
 169    def _radical_mz(self, ion_charge):
 170        """Get the m/z value of a radical ion version of the molecular formula.
 171
 172        Parameters
 173        ----------
 174        ion_charge : int
 175            The ion charge.
 176        """
 177        return (self.neutral_mass + (ion_charge * -1 * Atoms.electron_mass)) / abs(
 178            ion_charge
 179        )
 180
 181    @staticmethod
 182    def normalize_ion_type(ion_type: str) -> str:
 183        """Map alternate LCMS adduct labels onto ``ion_type_dict`` keys."""
 184        return MolecularFormulaCalc.ADDUCT_ALIASES.get(ion_type, ion_type)
 185
 186    @staticmethod
 187    def charge_from_adduct(adduct: str) -> int:
 188        """Parse integer charge from an adduct string (e.g. ``[M+H]+``, ``[M+2H]2+``)."""
 189        a = (adduct or "").strip()
 190        m = re.search(r"(\d+)([+-])$", a)
 191        if m:
 192            return int(m.group(1)) * (1 if m.group(2) == "+" else -1)
 193        if a.endswith("+"):
 194            return 1
 195        if a.endswith("-"):
 196            return -1
 197        raise ValueError(f"cannot parse charge from adduct {adduct!r}")
 198
 199    @staticmethod
 200    def get_ion_formula(neutral_formula, ion_type):
 201        """From a neutral formula and an ion type, return the formula of the ion.
 202
 203        Parameters
 204        ----------
 205        neutral_formula : str
 206            Neutral formula as MolecularFormula-style (``'C2 H4 O2'``) or compact
 207            (``'C2H4O2'``; isotopes only handled in the spaced form).
 208        ion_type : str
 209            Ion type / adduct key (see ``ion_type_dict``), or a known alias.
 210            LCMS adduct notation (e.g. ``[M+H]+``), not FTMS ``Labels`` ion types.
 211
 212        Returns
 213        -------
 214        str or None
 215            Ion formula string (e.g. ``'C2 H5 O2'``), or None if
 216            ``neutral_formula`` is not a string.
 217        """
 218        # Lazy import avoids circular dependency: factory inherits this class.
 219        from corems.molecular_formula.factory.MolecularFormulaFactory import (
 220            MolecularFormula,
 221        )
 222
 223        if not isinstance(neutral_formula, str):
 224            return None
 225
 226        ion_type = MolecularFormulaCalc.normalize_ion_type(ion_type)
 227        if ion_type not in MolecularFormulaCalc.ion_type_dict:
 228            raise KeyError(f"unsupported ion type {ion_type!r}")
 229
 230        if re.search(r"\s", neutral_formula):
 231            formula_obj = MolecularFormula(neutral_formula, ion_charge=0)
 232        else:
 233            form_pre = re.sub(r"([A-Z])", r" \1", neutral_formula)[1:]
 234            elements = [re.findall(r"[A-Z][a-z]*", x) for x in form_pre.split()]
 235            counts = [re.findall(r"\d+", x) for x in form_pre.split()]
 236            formula_obj = MolecularFormula(
 237                dict(
 238                    zip(
 239                        [x[0] for x in elements],
 240                        [int(x[0]) if x else 1 for x in counts],
 241                    )
 242                ),
 243                ion_charge=0,
 244            )
 245        neutral_formula_dict = formula_obj.to_dict().copy()
 246
 247        entry = MolecularFormulaCalc.ion_type_dict[ion_type]
 248        adduct_add_dict = entry["add"]
 249        for key in adduct_add_dict:
 250            if key in neutral_formula_dict:
 251                neutral_formula_dict[key] += adduct_add_dict[key]
 252            else:
 253                neutral_formula_dict[key] = adduct_add_dict[key]
 254
 255        adduct_subtract = entry["sub"]
 256        for key in adduct_subtract:
 257            neutral_formula_dict[key] -= adduct_subtract[key]
 258
 259        return MolecularFormula(neutral_formula_dict, ion_charge=0).string
 260
 261    @staticmethod
 262    def polarity_from_ion_type(ion_type: str) -> str:
 263        """Return ``'positive'`` or ``'negative'`` from ``ION_TYPE_DICT``."""
 264        ion_type = MolecularFormulaCalc.normalize_ion_type(ion_type)
 265        if ion_type not in ION_TYPE_DICT:
 266            raise KeyError(f"unsupported ion type {ion_type!r}")
 267        return ION_TYPE_DICT[ion_type]["polarity"]
 268
 269    @staticmethod
 270    def precursor_mz_from_formula(neutral_formula, ion_type, charge=None):
 271        """Calculated precursor m/z from neutral formula and adduct / ion type.
 272
 273        Parameters
 274        ----------
 275        neutral_formula : str
 276            Neutral molecular formula.
 277        ion_type : str
 278            Adduct or ion type (``ion_type_dict`` key or alias).
 279            LCMS adduct notation (e.g. ``[M-H]-``), not FTMS ``Labels`` ion types.
 280        charge : int, optional
 281            Ion charge. If omitted, inferred from ``ion_type``.
 282
 283        Returns
 284        -------
 285        float
 286            Precursor m/z of the ion.
 287
 288        Raises
 289        ------
 290        KeyError
 291            If ``ion_type`` is not supported.
 292        ValueError
 293            If the ion formula cannot be built or charge cannot be inferred.
 294        """
 295        # Lazy import avoids circular dependency: factory inherits this class.
 296        from corems.molecular_formula.factory.MolecularFormulaFactory import (
 297            MolecularFormula,
 298        )
 299
 300        ion_form = MolecularFormulaCalc.get_ion_formula(neutral_formula, ion_type)
 301        if ion_form is None:
 302            raise ValueError(
 303                f"could not build ion formula for {neutral_formula!r} {ion_type!r}"
 304            )
 305        if charge is None:
 306            charge = MolecularFormulaCalc.charge_from_adduct(
 307                MolecularFormulaCalc.normalize_ion_type(ion_type)
 308            )
 309        if charge == 0:
 310            raise ValueError("charge must be non-zero for precursor m/z")
 311        mf = MolecularFormula(ion_form, ion_charge=0)
 312        return float(
 313            (mf.neutral_mass + (charge * -1 * Atoms.electron_mass)) / abs(charge)
 314        )
 315
 316    def _neutral_mass(self):
 317        """Get the neutral mass of the molecular formula."""
 318
 319        mass = 0
 320
 321        for each_atom in self._d_molecular_formula.keys():
 322            if each_atom != Labels.ion_type and each_atom != "HC":
 323                try:
 324                    mass = mass + Atoms.atomic_masses[
 325                        each_atom
 326                    ] * self._d_molecular_formula.get(each_atom)
 327
 328                except:
 329                    print(Labels.ion_type, each_atom)
 330
 331        return mass
 332
 333    def _calc_mz(self):
 334        """Get the m/z value of the molecular formula, based on the ion charge and ion type."""
 335
 336        if self.ion_charge is not None:
 337            if self._external_mz:
 338                return self._external_mz
 339
 340            else:
 341                ion_type = self._d_molecular_formula.get(Labels.ion_type)
 342
 343                if ion_type == Labels.protonated_de_ion:
 344                    return self.protonated_mz
 345
 346                elif ion_type == Labels.radical_ion or ion_type == Labels.adduct_ion:
 347                    return self.radical_mz
 348
 349                elif ion_type == Labels.neutral:
 350                    return self.neutral_mass
 351
 352                elif self.ion_charge == 0:
 353                    return self.neutral_mass
 354
 355                else:
 356                    # formula is probably ion form used for bruker ref list
 357                    return self.neutral_mass
 358
 359        else:
 360            raise Exception("Please set ion charge first")
 361
 362    def _calc_assignment_mass_error(self, method="ppm"):
 363        """Calculate the mass error of the molecular formula, based on the experimental m/z and the calculated m/z.
 364
 365        Parameters
 366        ----------
 367        method : str, optional
 368            The method to calculate the mass error, by default 'ppm', but can be 'ppb'
 369
 370        Raises
 371        ------
 372        Exception
 373            If the method is not 'ppm' or 'ppb'.
 374        Exception
 375            If there is no ms peak associated with the molecular formula instance.
 376        """
 377
 378        if method == "ppm":
 379            multi_factor = 1000000
 380
 381        elif method == "ppb":
 382            multi_factor = 1000000
 383
 384        else:
 385            raise Exception(
 386                "method needs to be ppm or ppb, you have entered %s" % method
 387            )
 388
 389        if self._mspeak_parent.mz_exp:
 390            self._assignment_mass_error = (
 391                (self._mspeak_parent.mz_exp - self.mz_calc) / self.mz_calc
 392            ) * multi_factor
 393
 394            return (
 395                (self._mspeak_parent.mz_exp - self.mz_calc) / self.mz_calc
 396            ) * multi_factor
 397
 398        else:
 399            raise Exception(
 400                "No ms peak associated with the molecular formula instance %s", self
 401            )
 402
 403    def _calc_mz_confidence(self, mean=0):
 404        """Calculate the monoisotopic peak Gaussian mass-accuracy score.
 405
 406        Implementation for the public :attr:`mz_error_score` property.
 407        See that property and the ``corems.molecular_formula`` package
 408        documentation for the equation, ``predicted_std`` / 1.66 ppm
 409        fallback, and interpretation.
 410
 411        Parameters
 412        ----------
 413        mean : float, optional
 414            Mean mass error (ppm) for the Gaussian; default 0 (calibrated
 415            spectrum with near-zero mean error).
 416        """
 417
 418        # predicted_std not set: fallback width (ppm) for the Gaussian score
 419        if not self._mspeak_parent.predicted_std:
 420            self._mspeak_parent.predicted_std = 1.66
 421
 422        return exp(
 423            -1
 424            * (
 425                power((self.mz_error - mean), 2)
 426                / (2 * power(self._mspeak_parent.predicted_std, 2))
 427            )
 428        )
 429
 430    def _calc_isotopologue_confidence(self):
 431        """Calculate isotopologue abundance pattern similarity.
 432
 433        Implementation for the public :attr:`isotopologue_similarity`
 434        property. See that property and the ``corems.molecular_formula``
 435        package documentation for interpretation.
 436
 437        Returns
 438        -------
 439        float
 440            Similarity in about [0, 1], or 0.0 when no isotopologues are
 441            expected.
 442        """
 443
 444        if self.is_isotopologue:
 445            # confidence of isotopologue is pure mz error
 446            # TODO add more features here
 447
 448            mformula_index = self.mono_isotopic_formula_index
 449            mspeak_index = self.mspeak_index_mono_isotopic
 450            parent_ms = self._mspeak_parent._ms_parent
 451            if (
 452                mformula_index is None
 453                or mspeak_index is None
 454                or mspeak_index < 0
 455                or mspeak_index >= len(parent_ms)
 456            ):
 457                return 0.0
 458            mspeak = parent_ms[mspeak_index]
 459            if mformula_index < 0 or mformula_index >= len(mspeak):
 460                # Parent formula list was filtered (e.g. ion-type constrain);
 461                # stale isotopologue indexes should not fail export.
 462                return 0.0
 463
 464            expected_isotopologues = mspeak[mformula_index].expected_isotopologues
 465
 466            mono_mz = mspeak[mformula_index].mz_calc
 467            mono_abundance = mspeak.abundance
 468
 469        else:
 470            mono_mz = self.mz_calc
 471            mono_abundance = self._mspeak_parent.abundance
 472
 473            expected_isotopologues = self.expected_isotopologues
 474            # has isotopologues based on current dinamic range
 475
 476        if expected_isotopologues:
 477            dict_mz_abund_ref = {"mz": [mono_mz], "abundance": [mono_abundance]}
 478
 479            # get reference data
 480            for mf in expected_isotopologues:
 481                dict_mz_abund_ref["abundance"].append(mf.abundance_calc)
 482                dict_mz_abund_ref["mz"].append(mf.mz_calc)
 483
 484            dict_mz_abund_exp = {mono_mz: mono_abundance}
 485
 486            # get experimental data
 487            for mf in expected_isotopologues:
 488                # molecular formula has been assigned to a peak
 489                if mf._mspeak_parent:
 490                    # stores mspeak abundance
 491                    dict_mz_abund_exp[mf.mz_calc] = mf._mspeak_parent.abundance
 492
 493                else:
 494                    # fill missing mz with abundance 0 and mz error score of 0
 495                    dict_mz_abund_exp[mf.mz_calc] = nextafter(0, 1)
 496
 497            distance = SpectralSimilarity(
 498                dict_mz_abund_exp, dict_mz_abund_ref
 499            ).manhattan_distance()
 500            correlation = 1 - self.normalize_distance(distance, [0, 2])
 501            # correlation = dwt_correlation(dict_mz_abund_exp, dict_mz_abund_ref)
 502            # correlation = cosine_correlation(dict_mz_abund_exp, dict_mz_abund_ref)
 503
 504            if isnan(correlation):
 505                correlation = 0.00001
 506
 507        else:
 508            # no isotopologue expected giving a correlation score of 0.0 but it needs optimization
 509            correlation = 0.0
 510
 511        return correlation
 512
 513    def normalize_distance(self, dist, dist_range):
 514        """
 515        Normalize the distance value.
 516
 517        Parameters
 518        ----------
 519        dist : float
 520            The distance value to be normalized.
 521        dist_range : list
 522            The range of the distance value.
 523
 524        """
 525        result = (dist - dist_range[0]) / (dist_range[1] - dist_range[0])
 526
 527        if result < 0:
 528            result = 0.0
 529        elif result > 1:
 530            result = 1.0
 531
 532        return result
 533
 534    def subtract_formula(self, formula_obj, formated=True):
 535        """Subtract a formula from the current formula object
 536
 537        Parameters
 538        ----------
 539        formula_obj : MolecularFormula
 540            MolecularFormula object to be subtracted from the current formula object
 541        formated : bool, optional
 542            If True, returns the formula in string format, by default True
 543
 544        """
 545        subtraction = {}
 546        for atom, value in self.to_dict().items():
 547            if atom != Labels.ion_type:
 548                if formula_obj.get(atom):
 549                    # value_subtraction = value - formula_obj.get(atom)
 550                    if value - formula_obj.get(atom) > 0:
 551                        subtraction[atom] = value - formula_obj.get(atom)
 552                else:
 553                    subtraction[atom] = value
 554        if formated:
 555            SUB = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
 556            SUP = str.maketrans("0123456789", "⁰¹²³⁴⁵⁶⁷⁸⁹")
 557        else:
 558            SUB = str.maketrans("0123456789", "0123456789")
 559            SUP = str.maketrans("0123456789", "0123456789")
 560        formula_srt = ""
 561        for atom in Atoms.atoms_order:
 562            if atom in subtraction.keys():
 563                formula_srt += atom.translate(SUP) + str(
 564                    int(subtraction.get(atom))
 565                ).translate(SUB)
 566
 567        return formula_srt
 568
 569    def _calc_average_mz_score(self):
 570        """Average mass-accuracy score over mono and expected isotopologues.
 571
 572        Implementation for the public :attr:`average_mz_error_score`
 573        property. See that property and the ``corems.molecular_formula``
 574        package documentation for interpretation.
 575        """
 576        if self.is_isotopologue:
 577            # confidence of isotopologue is pure mz error
 578            # TODO add more features here
 579
 580            mformula_index = self.mono_isotopic_formula_index
 581            mspeak_index = self.mspeak_index_mono_isotopic
 582
 583            mspeak = self._mspeak_parent._ms_parent[mspeak_index]
 584
 585            expected_isotopologues = mspeak[mformula_index].expected_isotopologues
 586
 587        else:
 588            expected_isotopologues = self.expected_isotopologues
 589            # has isotopologues based on current dinamic range
 590
 591        accumulated_mz_score = [self.mz_error_score]
 592
 593        if expected_isotopologues:
 594            for mf in expected_isotopologues:
 595                # molecular formula has been assigned to a peak
 596                if mf._mspeak_parent:
 597                    # stores mspeak abundance
 598                    accumulated_mz_score.append(mf.mz_error_score)
 599                else:
 600                    # fill missing mz with abundance 0 and mz error score of 0
 601                    accumulated_mz_score.append(0.0)
 602
 603        average_mz_score = sum(accumulated_mz_score) / len(accumulated_mz_score)
 604
 605        if isnan(average_mz_score):
 606            average_mz_score = 0.0
 607
 608        return average_mz_score
 609
 610    def _calc_confidence_score(self):
 611        """Composite confidence score from mass error and isotopologues.
 612
 613        Implementation for the public :attr:`confidence_score` property:
 614
 615        ```
 616        CS = (w_err * average_mz_error_score) + (w_iso * isotopologue_similarity)
 617        ```
 618
 619        with weights from the parent spectrum's molecular search settings.
 620        Assumes a calibrated spectrum with near-zero mean mass error.
 621        See the public property and the ``corems.molecular_formula`` package
 622        documentation for the full formulation and literature reference.
 623        """
 624
 625        isotopologue_correlation = self.isotopologue_similarity
 626        average_mz_score = self.average_mz_error_score
 627        a = self._mspeak_parent._ms_parent.molecular_search_settings.mz_error_score_weight
 628        b = self._mspeak_parent._ms_parent.molecular_search_settings.isotopologue_score_weight
 629
 630        score = (isotopologue_correlation * b) + (average_mz_score * a)
 631
 632        return score
 633
 634    def _calc_abundance_error(self, method="percentile"):
 635        """Calculate the abundance error of the molecular formula, based on the experimental abundance and the calculated abundance.
 636
 637        Parameters
 638        ----------
 639        method : str, optional
 640            The method to calculate the abundance error, by default 'percentile', but can be 'ppm' or 'ppb'
 641
 642        Returns
 643        -------
 644        float
 645            The abundance error of the molecular formula.
 646
 647        Raises
 648        ------
 649        Exception
 650            If isotopologues were not calculated.
 651        """
 652
 653        mult_factor = 100
 654
 655        iso_abundance = self._mspeak_parent.abundance
 656        mono_abundance = self._mspeak_parent._ms_parent[
 657            self.mspeak_index_mono_isotopic
 658        ].abundance
 659
 660        if self.prob_ratio:
 661            theor_abundance = mono_abundance * self.prob_ratio
 662            # self.parent need to have a MassSpecPeak associated with the MolecularFormula class
 663            return ((theor_abundance - iso_abundance) / theor_abundance) * mult_factor
 664
 665        else:
 666            raise Exception("Please calc_isotopologues")
 667
 668    def _calc_area_error(self, method="percentile"):
 669        """Calculate the area error of the molecular formula, based on the experimental area and the calculated area.
 670
 671        Parameters
 672        ----------
 673        method : str, optional
 674            The method to calculate the area error, by default 'percentile', but can be 'ppm' or 'ppb'
 675
 676        Returns
 677        -------
 678        float
 679            The area error of the molecular formula.
 680
 681        Raises
 682        ------
 683        Exception
 684            If isotopologues were not calculated.
 685        """
 686
 687        mult_factor = 100
 688
 689        iso_area = self._mspeak_parent.area
 690        mono_area = self._mspeak_parent._ms_parent[self.mspeak_index_mono_isotopic].area
 691
 692        if self.prob_ratio:
 693            if mono_area and iso_area:
 694                # exp_ratio = iso_area/mono_area
 695
 696                area_calc = mono_area * self.prob_ratio
 697
 698                # self.parent need to have a MassSpecPeak associated with the MolecularFormula class
 699                return ((area_calc - iso_area) / area_calc) * mult_factor
 700                # return ((self.prob_ratio - exp_ratio )/self.prob_ratio)*mult_factor
 701
 702            else:
 703                # centroid mass spectrum
 704                return 0
 705        else:
 706            raise Exception("Please calc_isotopologues")
 707
 708    def _calc_aromaticity_index_mod(self):
 709        """Calculate the modified aromaticity index of the molecular formula.
 710
 711        Returns
 712        -------
 713        float
 714            The aromaticity index of the molecular formula.
 715
 716        Notes
 717        -----
 718        Source Koch and Dittmar, 2006 https://doi.org/10.1002/rcm.2386
 719        corrected in https://doi.org/10.1002/rcm.7433
 720        """
 721        # Prepare empty dictionary to store the number of atoms of each element
 722        ai_es = {"C": 0, "H": 0, "O": 0, "N": 0, "S": 0}
 723
 724        # Count the number of atoms of each element in the molecular formula, inclusive of isotopes
 725        for element in ai_es:
 726            elements_w_iso = [element] + Atoms.isotopes.get(element)[1]
 727            for element_w_iso in elements_w_iso:
 728                if element_w_iso in self._d_molecular_formula:
 729                    ai_es[element] += self._d_molecular_formula[element_w_iso]
 730
 731        ai_n = (
 732            1
 733            + ai_es["C"]
 734            - (0.5 * ai_es["O"])
 735            - ai_es["S"]
 736            - (0.5 * (ai_es["N"] + ai_es["H"]))
 737        )
 738        ai_d = ai_es["C"] - (0.5 * ai_es["O"]) - ai_es["N"] - ai_es["S"]
 739
 740        ai = ai_n / ai_d
 741
 742        if ai < 0:
 743            ai = 0
 744        if ai > 1:
 745            ai = 1
 746
 747        return ai
 748
 749    def _calc_aromaticity_index(self):
 750        """Calculate the aromaticity index of the molecular formula.
 751
 752        Returns
 753        -------
 754        float
 755            The aromaticity index of the molecular formula.
 756
 757        Notes
 758        -----
 759        Source Koch and Dittmar, 2006 https://doi.org/10.1002/rcm.2386
 760        corrected in https://doi.org/10.1002/rcm.7433
 761        """
 762        # Prepare empty dictionary to store the number of atoms of each element
 763        ai_es = {"C": 0, "H": 0, "O": 0, "N": 0, "S": 0}
 764
 765        # Count the number of atoms of each element in the molecular formula, inclusive of isotopes
 766        for element in ai_es:
 767            elements_w_iso = [element] + Atoms.isotopes.get(element)[1]
 768            for element_w_iso in elements_w_iso:
 769                if element_w_iso in self._d_molecular_formula:
 770                    ai_es[element] += self._d_molecular_formula[element_w_iso]
 771
 772                ai_n = (
 773                    1
 774                    + ai_es["C"]
 775                    - (ai_es["O"])
 776                    - ai_es["S"]
 777                    - (0.5 * (ai_es["N"] + ai_es["H"]))
 778                )
 779        ai_d = ai_es["C"] - (ai_es["O"]) - ai_es["N"] - ai_es["S"]
 780
 781        ai = ai_n / ai_d
 782
 783        if ai < 0:
 784            ai = 0
 785        if ai > 1:
 786            ai = 1
 787
 788        return ai
 789
 790    def _calc_nosc(self):
 791        """Calculate the average nominal oxidation state of carbon
 792
 793        Returns
 794        -------
 795        float
 796            The average nominal oxidation state of carbon
 797
 798        Notes
 799        -----
 800        Source LaRowe and Van Cappellen, 2011 https://doi.org/10.1016/j.gca.2011.01.020
 801        """
 802        # Prepare empty dictionary to store the number of atoms of each element
 803        nosc_es = {"C": 0, "H": 0, "O": 0, "N": 0, "S": 0, "P": 0}
 804
 805        # Count the number of atoms of each element in the molecular formula, inclusive of isotopes
 806        for element in nosc_es:
 807            elements_w_iso = [element] + Atoms.isotopes.get(element)[1]
 808            for element_w_iso in elements_w_iso:
 809                if element_w_iso in self._d_molecular_formula:
 810                    nosc_es[element] += self._d_molecular_formula[element_w_iso]
 811
 812        nosc = (
 813            -(
 814                (
 815                    4 * nosc_es["C"]
 816                    + nosc_es["H"]
 817                    - 3 * nosc_es["N"]
 818                    - 2 * nosc_es["O"]
 819                    + 5 * nosc_es["P"]
 820                    - 2 * nosc_es["S"]
 821                )
 822                / nosc_es["C"]
 823            )
 824            + 4
 825        )
 826
 827        # If nosc is infinite or negative infinity, set it to nan
 828        if nosc == float("inf") or nosc == float("-inf"):
 829            nosc = float("nan")
 830
 831        return nosc
 832
 833    @property
 834    def dbe_ai(self):
 835        """Calculate the double bond equivalent (DBE) of the molecular formula, based on the number of carbons, hydrogens, and oxygens."""
 836
 837        carbons = self._d_molecular_formula.get("C")
 838        hydrogens = self._d_molecular_formula.get("H")
 839        oxygens = self._d_molecular_formula.get("O")
 840        return 1 + (((2 * carbons) - hydrogens - (2 * oxygens)) * 0.5)
 841
 842    def _calc_dbe(self):
 843        """Calculate the double bond equivalent (DBE) of the molecular formula"""
 844
 845        individual_dbe = 0
 846
 847        for atom in self._d_molecular_formula.keys():
 848            if atom != Labels.ion_type:
 849                n_atom = int(self._d_molecular_formula.get(atom))
 850
 851                clean_atom = "".join([i for i in atom if not i.isdigit()])
 852
 853                if self._mspeak_parent:
 854                    valencia = self._mspeak_parent._ms_parent.molecular_search_settings.used_atom_valences.get(
 855                        clean_atom
 856                    )
 857                else:
 858                    valencia = MSParameters.molecular_search.used_atom_valences.get(
 859                        clean_atom
 860                    )
 861                # valencia = Atoms.atoms_covalence.get(atom)
 862
 863                if type(valencia) is tuple:
 864                    valencia = valencia[0]
 865                if valencia > 0:
 866                    # print atom, valencia, n_atom, individual_dbe
 867                    individual_dbe = individual_dbe + (n_atom * (valencia - 2))
 868                else:
 869                    continue
 870
 871        dbe = 1 + (0.5 * individual_dbe)
 872
 873        if self.ion_type == Labels.adduct_ion:
 874            dbe = dbe + 0.5
 875
 876        return dbe
 877
 878    def _calc_kmd(self, dict_base):
 879        """Calculate the Kendrick mass defect (KMD) of the molecular formula, based on the monoisotopic mass and the Kendrick mass.
 880
 881        Parameters
 882        ----------
 883        dict_base : dict
 884            The dictionary of the base formula, e.g. {'C':1, 'H':2}
 885
 886        Returns
 887        -------
 888        tuple
 889            The tuple of the KMD, Kendrick mass, and nominal Kendrick mass.
 890        """
 891        mass = 0
 892        for atom in dict_base.keys():
 893            mass = mass + Atoms.atomic_masses.get(atom) * dict_base.get(atom)
 894
 895        kendrick_mass = (int(mass) / mass) * self.mz_calc
 896
 897        nominal_km = int(kendrick_mass)
 898
 899        kmd = (nominal_km - kendrick_mass) * 100
 900
 901        # kmd = (nominal_km - km) * 1
 902        kmd = round(kmd, 0)
 903
 904        return kmd, kendrick_mass, nominal_km
 905
 906    def _cal_isotopologues(
 907        self, formula_dict, min_abundance, current_abundance, ms_dynamic_range
 908    ):
 909        """Calculate the isotopologues for a given molecular formula.
 910
 911        Parameters
 912        ----------
 913        formula_dict : dict
 914            The dictionary of the molecular formula. Example: {'C':10, 'H', 20, 'O', 2}
 915        min_abundance : float
 916            The minimum abundance.
 917        current_abundance : float
 918            The current monoisotopic abundance.
 919        ms_dynamic_range : float
 920            The dynamic range.
 921
 922
 923        Notes
 924        -----
 925        This is the primary function to look for isotopologues based on a monoisotopic molecular formula.
 926        It needs to be expanded to include the calculation of resolving power and plot the results.
 927        Use this function at runtime during the molecular identification algorithm only when a positive ID is observed to the monoisotopic ion.
 928        Use this function to simulate mass spectrum (needs resolving power calculation to be fully operational).
 929        It might break when adding non-conventional atoms (not yet tested).
 930        This function employs the IsoSpecPy library https://github.com/MatteoLacki/IsoSpec.
 931
 932
 933        """
 934
 935        # last update on 05-26-2020, Yuri E. Corilo
 936
 937        # updated it to reflect min possible mass peak abundance
 938        cut_off_to_IsoSpeccPy = 1 - (1 / ms_dynamic_range)
 939
 940        # print("cut_off_to_IsoSpeccPy", cut_off_to_IsoSpeccPy, current_abundance, min_abundance, ms_dynamic_range)
 941        # print(cut_off_to_IsoSpeccPy)
 942        atoms_labels = (
 943            atom
 944            for atom in formula_dict.keys()
 945            if atom != Labels.ion_type and atom != "H"
 946        )
 947
 948        atoms_count = []
 949        masses_list_tuples = []
 950        props_list_tuples = []
 951        all_atoms_list = []
 952
 953        for atom_label in atoms_labels:
 954            if Atoms.isotopes.get(atom_label)[1][0] is None:
 955                "This atom_label has no heavy isotope"
 956                atoms_count.append(formula_dict.get(atom_label))
 957                mass = Atoms.atomic_masses.get(atom_label)
 958                prop = Atoms.isotopic_abundance.get(atom_label)
 959                masses_list_tuples.append([mass])
 960                props_list_tuples.append([prop])
 961                all_atoms_list.append(atom_label)
 962
 963            else:
 964                isotopes_label_list = Atoms.isotopes.get(atom_label)[1]
 965
 966                if len(isotopes_label_list) > 1:
 967                    "This atom_label has two or more heavy isotope"
 968                    isotopos_labels = [i for i in isotopes_label_list]
 969                else:
 970                    "This atom_label only has one heavy isotope"
 971                    isotopos_labels = [isotopes_label_list[0]]
 972
 973                # all_atoms_list.extend(isotopos_labels)
 974                isotopos_labels = [atom_label] + isotopos_labels
 975
 976                all_atoms_list.extend(isotopos_labels)
 977
 978                masses = [
 979                    Atoms.atomic_masses.get(atom_label)
 980                    for atom_label in isotopos_labels
 981                ]
 982                props = [
 983                    Atoms.isotopic_abundance.get(atom_label)
 984                    for atom_label in isotopos_labels
 985                ]
 986
 987                atoms_count.append(formula_dict.get(atom_label))
 988                masses_list_tuples.append(masses)
 989                props_list_tuples.append(props)
 990        if legacy_isospec:
 991            iso = IsoSpecPy.IsoSpec(
 992                atoms_count,
 993                masses_list_tuples,
 994                props_list_tuples,
 995                cut_off_to_IsoSpeccPy,
 996            )
 997            conf = iso.getConfs()
 998            masses = conf[0]
 999            probs = exp(conf[1])
1000            molecular_formulas = conf[2]
1001            # print('conf', conf)
1002            # print('probs', conf[1])
1003        else:
1004            # This syntax in IsoSpecPy 2.2.2 yields the same information as the legacy approach
1005            iso = IsoSpecPy.IsoTotalProb(
1006                atomCounts=atoms_count,
1007                isotopeMasses=masses_list_tuples,
1008                isotopeProbabilities=props_list_tuples,
1009                prob_to_cover=cut_off_to_IsoSpeccPy,
1010                get_confs=True,
1011            )
1012            masses = list(iso.masses)
1013            probs = array(list(iso.probs))
1014            confs = list(iso.confs)
1015
1016            molecular_formulas = []
1017            for x in confs:
1018                tmplist = []
1019                for y in x:
1020                    tmplist.extend(list(y))
1021                molecular_formulas.append(tmplist)
1022
1023        new_formulas = []
1024
1025        for isotopologue_index in range(len(iso)):
1026            # skip_mono_isotopic
1027
1028            formula_list = molecular_formulas[isotopologue_index]
1029            new_formula_dict = dict(zip(all_atoms_list, formula_list))
1030            new_formula_dict[Labels.ion_type] = formula_dict.get(Labels.ion_type)
1031            if formula_dict.get("H"):
1032                new_formula_dict["H"] = formula_dict.get("H")
1033
1034            new_formulas.append({x: y for x, y in new_formula_dict.items() if y != 0})
1035
1036        # formula_dict in new_formulas check if monoisotopic is being returned
1037        if new_formulas:  # and formula_dict in new_formulas:
1038            # print(conf)
1039            # print(new_formulas)
1040            # print(atoms_count)
1041            # print(all_atoms_list)
1042            # print(masses_list_tuples)
1043            # print(props_list_tuples)
1044            # find where monoisotopic is
1045            index_mono = new_formulas.index(formula_dict)
1046            # calculate ratio iso/mono
1047            probs = list(probs / probs[index_mono])
1048
1049            # delete the monoisotopic
1050            del probs[index_mono]
1051            del new_formulas[index_mono]
1052
1053            # print('probs_exp', probs)
1054            for formulas, prob in zip(new_formulas, probs):
1055                theor_abundance = current_abundance * prob
1056                if theor_abundance > min_abundance:
1057                    # print(prob, theor_abundance, current_abundance)
1058                    yield (formulas, prob)
1059            # return zip(new_formulas, probs )
1060
1061        # else:
1062        #    return []

Class of calculations related to molecular formula

This class is not intended to be used directly, but rather to be inherited by other classes in the molecular_formula/factory module like MolecularFormula and MolecularFormulaIsotopologue

Attributes
  • mz_calc (float): The m/z value of the molecular formula.
  • neutral_mass (float): The neutral mass of the molecular formula.
  • ion_charge (int): The ion charge of the molecular formula.
  • _external_mz (float): The externally provided m/z value of the molecular formula.
  • _d_molecular_formula (dict): The dictionary representation of the molecular formula.
  • _mspeak_parent (object): The parent MS peak object associated with the molecular formula.
  • _assignment_mass_error (float): The mass error of the molecular formula.
Methods
  • _calc_resolving_power_low_pressure(B, T) Calculate the resolving power at low pressure.
  • _calc_resolving_power_high_pressure(B, T) Calculate the resolving power at high pressure.
  • _adduct_mz(adduct_atom, ion_charge) Get the m/z value of an adducted ion version of the molecular formula.
  • _protonated_mz(ion_charge) Get the m/z value of a protonated or deprotonated ion version of the molecular formula.
  • _radical_mz(ion_charge) Get the m/z value of a radical ion version of the molecular formula.
  • normalize_ion_type(ion_type) Map alternate LCMS adduct labels onto ion_type_dict keys.
  • charge_from_adduct(adduct) Parse integer charge from an LCMS adduct string.
  • get_ion_formula(neutral_formula, ion_type) Build ion formula string from neutral formula and LCMS adduct / ion type.
  • precursor_mz_from_formula(neutral_formula, ion_type, charge=None) Calculate precursor m/z from neutral formula and LCMS adduct / ion type.
  • _neutral_mass() Get the neutral mass of the molecular formula.
  • _calc_mz() Get the m/z value of the molecular formula.
  • _calc_assignment_mass_error(method='ppm') Calculate the mass error of the molecular formula.
  • _calc_mz_confidence(mean=0) Calculate the m/z confidence of the molecular formula.
  • _calc_isotopologue_confidence() Calculate the isotopologue confidence of the molecular formula.
  • normalize_distance(dist, dist_range) Normalize the distance value.
  • subtract_formula(formula_obj, formated=True) Subtract a formula from the current formula object.
  • _calc_average_mz_score() Calculate the average m/z error score of the molecular formula identification, including the isotopologues.
Notes

LCMS adduct helpers use ~corems.encapsulation.constant.ION_TYPE_DICT ([M+H]+ / [M-H]-, etc.). Distinct from FTMS assignment labels in ~corems.encapsulation.constant.Labels (DE_OR_PROTONATED, RADICAL, ADDUCT) used by _calc_mz.

ion_type_dict = {'M+': {'add': {}, 'sub': {}, 'polarity': 'positive'}, '[M]+': {'add': {}, 'sub': {}, 'polarity': 'positive'}, 'protonated': {'add': {'H': 1}, 'sub': {}, 'polarity': 'positive'}, '[M+H]+': {'add': {'H': 1}, 'sub': {}, 'polarity': 'positive'}, '[M+2H]2+': {'add': {'H': 2}, 'sub': {}, 'polarity': 'positive'}, '[M+3H]3+': {'add': {'H': 3}, 'sub': {}, 'polarity': 'positive'}, '[M+NH4]+': {'add': {'N': 1, 'H': 4}, 'sub': {}, 'polarity': 'positive'}, '[M+Na]+': {'add': {'Na': 1}, 'sub': {}, 'polarity': 'positive'}, '[M+K]+': {'add': {'K': 1}, 'sub': {}, 'polarity': 'positive'}, '[M+2Na]2+': {'add': {'Na': 2}, 'sub': {}, 'polarity': 'positive'}, '[M+H+Na]2+': {'add': {'H': 1, 'Na': 1}, 'sub': {}, 'polarity': 'positive'}, '[M+H+K]2+': {'add': {'H': 1, 'K': 1}, 'sub': {}, 'polarity': 'positive'}, '[M+2Na+Cl]+': {'add': {'Na': 2, 'Cl': 1}, 'sub': {}, 'polarity': 'positive'}, '[M+2Na-H]+': {'add': {'Na': 2}, 'sub': {'H': 1}, 'polarity': 'positive'}, '[M-H+2Na]+': {'add': {'Na': 2}, 'sub': {'H': 1}, 'polarity': 'positive'}, '[M+C2H3Na2O2]+': {'add': {'C': 2, 'H': 3, 'Na': 2, 'O': 2}, 'sub': {}, 'polarity': 'positive'}, '[M+C4H10N3]+': {'add': {'C': 4, 'H': 10, 'N': 3}, 'sub': {}, 'polarity': 'positive'}, '[M+NH4+ACN]+': {'add': {'C': 2, 'H': 7, 'N': 2}, 'sub': {}, 'polarity': 'positive'}, '[M+H-H2O]+': {'add': {}, 'sub': {'H': 1, 'O': 1}, 'polarity': 'positive'}, '[M+H-2H2O]+': {'add': {}, 'sub': {'H': 3, 'O': 2}, 'polarity': 'positive'}, '[M+H-NH3]+': {'add': {}, 'sub': {'N': 1, 'H': 2}, 'polarity': 'positive'}, '[M+2H-NH3]2+': {'add': {}, 'sub': {'N': 1, 'H': 1}, 'polarity': 'positive'}, '[M+2H-H2O]2+': {'add': {}, 'sub': {'O': 1}, 'polarity': 'positive'}, '[M+NH4-H2O]+': {'add': {'N': 1, 'H': 2}, 'sub': {}, 'polarity': 'positive'}, '[M+H+H2O]+': {'add': {'H': 3, 'O': 1}, 'sub': {}, 'polarity': 'positive'}, 'de-protonated': {'add': {}, 'sub': {'H': 1}, 'polarity': 'negative'}, '[M-H]-': {'add': {}, 'sub': {'H': 1}, 'polarity': 'negative'}, '[M-2H]2-': {'add': {}, 'sub': {'H': 2}, 'polarity': 'negative'}, '[M-H-H2O]-': {'add': {}, 'sub': {'H': 3, 'O': 1}, 'polarity': 'negative'}, '[M-H+H2O]-': {'add': {'H': 1, 'O': 1}, 'sub': {}, 'polarity': 'negative'}, '[M+Cl]-': {'add': {'Cl': 1}, 'sub': {}, 'polarity': 'negative'}, '[M+HCOO]-': {'add': {'C': 1, 'H': 1, 'O': 2}, 'sub': {}, 'polarity': 'negative'}, '[M+CH3COO]-': {'add': {'C': 2, 'H': 3, 'O': 2}, 'sub': {}, 'polarity': 'negative'}, '[M+2NaAc+Cl]-': {'add': {'Na': 2, 'C': 2, 'H': 3, 'O': 2, 'Cl': 1}, 'sub': {}, 'polarity': 'negative'}, '[M+K-2H]-': {'add': {'K': 1}, 'sub': {'H': 2}, 'polarity': 'negative'}, '[M+Na-2H]-': {'add': {'Na': 1}, 'sub': {'H': 2}, 'polarity': 'negative'}}
ADDUCT_ALIASES = {'[M+HCOOH-H]-': '[M+HCOO]-', '[M+CH3COOH-H]-': '[M+CH3COO]-', '[M+FA-H]-': '[M+HCOO]-', '[M+AcOH-H]-': '[M+CH3COO]-'}
@staticmethod
def normalize_ion_type(ion_type: str) -> str:
181    @staticmethod
182    def normalize_ion_type(ion_type: str) -> str:
183        """Map alternate LCMS adduct labels onto ``ion_type_dict`` keys."""
184        return MolecularFormulaCalc.ADDUCT_ALIASES.get(ion_type, ion_type)

Map alternate LCMS adduct labels onto ion_type_dict keys.

@staticmethod
def charge_from_adduct(adduct: str) -> int:
186    @staticmethod
187    def charge_from_adduct(adduct: str) -> int:
188        """Parse integer charge from an adduct string (e.g. ``[M+H]+``, ``[M+2H]2+``)."""
189        a = (adduct or "").strip()
190        m = re.search(r"(\d+)([+-])$", a)
191        if m:
192            return int(m.group(1)) * (1 if m.group(2) == "+" else -1)
193        if a.endswith("+"):
194            return 1
195        if a.endswith("-"):
196            return -1
197        raise ValueError(f"cannot parse charge from adduct {adduct!r}")

Parse integer charge from an adduct string (e.g. [M+H]+, [M+2H]2+).

@staticmethod
def get_ion_formula(neutral_formula, ion_type):
199    @staticmethod
200    def get_ion_formula(neutral_formula, ion_type):
201        """From a neutral formula and an ion type, return the formula of the ion.
202
203        Parameters
204        ----------
205        neutral_formula : str
206            Neutral formula as MolecularFormula-style (``'C2 H4 O2'``) or compact
207            (``'C2H4O2'``; isotopes only handled in the spaced form).
208        ion_type : str
209            Ion type / adduct key (see ``ion_type_dict``), or a known alias.
210            LCMS adduct notation (e.g. ``[M+H]+``), not FTMS ``Labels`` ion types.
211
212        Returns
213        -------
214        str or None
215            Ion formula string (e.g. ``'C2 H5 O2'``), or None if
216            ``neutral_formula`` is not a string.
217        """
218        # Lazy import avoids circular dependency: factory inherits this class.
219        from corems.molecular_formula.factory.MolecularFormulaFactory import (
220            MolecularFormula,
221        )
222
223        if not isinstance(neutral_formula, str):
224            return None
225
226        ion_type = MolecularFormulaCalc.normalize_ion_type(ion_type)
227        if ion_type not in MolecularFormulaCalc.ion_type_dict:
228            raise KeyError(f"unsupported ion type {ion_type!r}")
229
230        if re.search(r"\s", neutral_formula):
231            formula_obj = MolecularFormula(neutral_formula, ion_charge=0)
232        else:
233            form_pre = re.sub(r"([A-Z])", r" \1", neutral_formula)[1:]
234            elements = [re.findall(r"[A-Z][a-z]*", x) for x in form_pre.split()]
235            counts = [re.findall(r"\d+", x) for x in form_pre.split()]
236            formula_obj = MolecularFormula(
237                dict(
238                    zip(
239                        [x[0] for x in elements],
240                        [int(x[0]) if x else 1 for x in counts],
241                    )
242                ),
243                ion_charge=0,
244            )
245        neutral_formula_dict = formula_obj.to_dict().copy()
246
247        entry = MolecularFormulaCalc.ion_type_dict[ion_type]
248        adduct_add_dict = entry["add"]
249        for key in adduct_add_dict:
250            if key in neutral_formula_dict:
251                neutral_formula_dict[key] += adduct_add_dict[key]
252            else:
253                neutral_formula_dict[key] = adduct_add_dict[key]
254
255        adduct_subtract = entry["sub"]
256        for key in adduct_subtract:
257            neutral_formula_dict[key] -= adduct_subtract[key]
258
259        return MolecularFormula(neutral_formula_dict, ion_charge=0).string

From a neutral formula and an ion type, return the formula of the ion.

Parameters
  • neutral_formula (str): Neutral formula as MolecularFormula-style ('C2 H4 O2') or compact ('C2H4O2'; isotopes only handled in the spaced form).
  • ion_type (str): Ion type / adduct key (see ion_type_dict), or a known alias. LCMS adduct notation (e.g. [M+H]+), not FTMS Labels ion types.
Returns
  • str or None: Ion formula string (e.g. 'C2 H5 O2'), or None if neutral_formula is not a string.
@staticmethod
def polarity_from_ion_type(ion_type: str) -> str:
261    @staticmethod
262    def polarity_from_ion_type(ion_type: str) -> str:
263        """Return ``'positive'`` or ``'negative'`` from ``ION_TYPE_DICT``."""
264        ion_type = MolecularFormulaCalc.normalize_ion_type(ion_type)
265        if ion_type not in ION_TYPE_DICT:
266            raise KeyError(f"unsupported ion type {ion_type!r}")
267        return ION_TYPE_DICT[ion_type]["polarity"]

Return 'positive' or 'negative' from ION_TYPE_DICT.

@staticmethod
def precursor_mz_from_formula(neutral_formula, ion_type, charge=None):
269    @staticmethod
270    def precursor_mz_from_formula(neutral_formula, ion_type, charge=None):
271        """Calculated precursor m/z from neutral formula and adduct / ion type.
272
273        Parameters
274        ----------
275        neutral_formula : str
276            Neutral molecular formula.
277        ion_type : str
278            Adduct or ion type (``ion_type_dict`` key or alias).
279            LCMS adduct notation (e.g. ``[M-H]-``), not FTMS ``Labels`` ion types.
280        charge : int, optional
281            Ion charge. If omitted, inferred from ``ion_type``.
282
283        Returns
284        -------
285        float
286            Precursor m/z of the ion.
287
288        Raises
289        ------
290        KeyError
291            If ``ion_type`` is not supported.
292        ValueError
293            If the ion formula cannot be built or charge cannot be inferred.
294        """
295        # Lazy import avoids circular dependency: factory inherits this class.
296        from corems.molecular_formula.factory.MolecularFormulaFactory import (
297            MolecularFormula,
298        )
299
300        ion_form = MolecularFormulaCalc.get_ion_formula(neutral_formula, ion_type)
301        if ion_form is None:
302            raise ValueError(
303                f"could not build ion formula for {neutral_formula!r} {ion_type!r}"
304            )
305        if charge is None:
306            charge = MolecularFormulaCalc.charge_from_adduct(
307                MolecularFormulaCalc.normalize_ion_type(ion_type)
308            )
309        if charge == 0:
310            raise ValueError("charge must be non-zero for precursor m/z")
311        mf = MolecularFormula(ion_form, ion_charge=0)
312        return float(
313            (mf.neutral_mass + (charge * -1 * Atoms.electron_mass)) / abs(charge)
314        )

Calculated precursor m/z from neutral formula and adduct / ion type.

Parameters
  • neutral_formula (str): Neutral molecular formula.
  • ion_type (str): Adduct or ion type (ion_type_dict key or alias). LCMS adduct notation (e.g. [M-H]-), not FTMS Labels ion types.
  • charge (int, optional): Ion charge. If omitted, inferred from ion_type.
Returns
  • float: Precursor m/z of the ion.
Raises
  • KeyError: If ion_type is not supported.
  • ValueError: If the ion formula cannot be built or charge cannot be inferred.
def normalize_distance(self, dist, dist_range):
513    def normalize_distance(self, dist, dist_range):
514        """
515        Normalize the distance value.
516
517        Parameters
518        ----------
519        dist : float
520            The distance value to be normalized.
521        dist_range : list
522            The range of the distance value.
523
524        """
525        result = (dist - dist_range[0]) / (dist_range[1] - dist_range[0])
526
527        if result < 0:
528            result = 0.0
529        elif result > 1:
530            result = 1.0
531
532        return result

Normalize the distance value.

Parameters
  • dist (float): The distance value to be normalized.
  • dist_range (list): The range of the distance value.
def subtract_formula(self, formula_obj, formated=True):
534    def subtract_formula(self, formula_obj, formated=True):
535        """Subtract a formula from the current formula object
536
537        Parameters
538        ----------
539        formula_obj : MolecularFormula
540            MolecularFormula object to be subtracted from the current formula object
541        formated : bool, optional
542            If True, returns the formula in string format, by default True
543
544        """
545        subtraction = {}
546        for atom, value in self.to_dict().items():
547            if atom != Labels.ion_type:
548                if formula_obj.get(atom):
549                    # value_subtraction = value - formula_obj.get(atom)
550                    if value - formula_obj.get(atom) > 0:
551                        subtraction[atom] = value - formula_obj.get(atom)
552                else:
553                    subtraction[atom] = value
554        if formated:
555            SUB = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
556            SUP = str.maketrans("0123456789", "⁰¹²³⁴⁵⁶⁷⁸⁹")
557        else:
558            SUB = str.maketrans("0123456789", "0123456789")
559            SUP = str.maketrans("0123456789", "0123456789")
560        formula_srt = ""
561        for atom in Atoms.atoms_order:
562            if atom in subtraction.keys():
563                formula_srt += atom.translate(SUP) + str(
564                    int(subtraction.get(atom))
565                ).translate(SUB)
566
567        return formula_srt

Subtract a formula from the current formula object

Parameters
  • formula_obj (MolecularFormula): MolecularFormula object to be subtracted from the current formula object
  • formated (bool, optional): If True, returns the formula in string format, by default True
dbe_ai
833    @property
834    def dbe_ai(self):
835        """Calculate the double bond equivalent (DBE) of the molecular formula, based on the number of carbons, hydrogens, and oxygens."""
836
837        carbons = self._d_molecular_formula.get("C")
838        hydrogens = self._d_molecular_formula.get("H")
839        oxygens = self._d_molecular_formula.get("O")
840        return 1 + (((2 * carbons) - hydrogens - (2 * oxygens)) * 0.5)

Calculate the double bond equivalent (DBE) of the molecular formula, based on the number of carbons, hydrogens, and oxygens.