Source code for spectrochempy.core.units

# ======================================================================================
# Copyright (©) 2015-2025 LCS - Laboratoire Catalyse et Spectrochimie, Caen, France.
# CeCILL-B FREE SOFTWARE LICENSE AGREEMENT
# See full LICENSE agreement in the root directory.
# ======================================================================================
"""The core interface to the Pint library."""

__all__ = [
    "Unit",
    "Quantity",
    "ur",
    "set_nmr_context",
    "DimensionalityError",
]

import warnings

from pint import __version__

# check pint version
pint_version = int(__version__.split(".")[1])
if pint_version < 20:
    raise ImportError(
        "Current pint version is {__version__} but must be 0.20 or higher. Please consider upgrading it "
        "(e.g. `> pip install pint --upgrade` or `> conda update pint` )\n",
    )
if pint_version < 24:
    warnings.warn(
        f"Warning: current pint version is {__version__}. It might not be supported by SpectroChemPy in the future.\n"
        f"Please consider upgrading it to 0.24 or higher (e.g. `> pip install pint --upgrade` or `> conda update pint`)\n",
        stacklevel=2,
    )

if pint_version < 24:
    from functools import wraps

    from pint import Context
    from pint import DimensionalityError
    from pint import Quantity
    from pint import Unit
    from pint import UnitRegistry
    from pint import __version__
    from pint import formatting
    from pint import set_application_registry
    from pint.facets.plain import ScaleConverter
    from pint.facets.plain import UnitDefinition
    from pint.util import UnitsContainer

    # ======================================================================================
    # Modify the pint behaviour
    # ======================================================================================
    formats = {
        "P": {  # Pretty format.
            "as_ratio": False,  # True in pint
            "single_denominator": False,
            "product_fmt": "·",
            "division_fmt": "/",
            "power_fmt": "{}{}",
            "parentheses_fmt": "({})",
            "exp_call": formatting._pretty_fmt_exponent,
        },
        "L": {  # Latex format.
            "as_ratio": False,  # True in pint
            "single_denominator": True,
            "product_fmt": r" \cdot ",
            "division_fmt": r"\frac[{}][{}]",
            "power_fmt": "{}^[{}]",
            "parentheses_fmt": r"\left({}\right)",
        },
        "H": {  # HTML format.
            "as_ratio": False,  # True in pint
            "single_denominator": False,
            "product_fmt": r" ",
            "division_fmt": r"{}/{}",
            "power_fmt": r"{}<sup>{}</sup>",
            "parentheses_fmt": r"({})",
        },
        "": {  # Default format.
            "as_ratio": True,
            "single_denominator": False,
            "product_fmt": " * ",
            "division_fmt": " / ",
            "power_fmt": "{} ** {}",
            "parentheses_fmt": r"({})",
        },
        "C": {  # Compact format.
            "as_ratio": False,
            "single_denominator": False,
            "product_fmt": "*",  # TODO: Should this just be ''?
            "division_fmt": "/",
            "power_fmt": "{}**{}",
            "parentheses_fmt": r"({})",
        },
        "K": {  # spectrochempy Compact format.
            "as_ratio": False,
            "single_denominator": False,
            "product_fmt": " ",
            "division_fmt": "/",
            "power_fmt": "{}^{}",
            "parentheses_fmt": r"({})",
        },
    }

    del formatting._FORMATTERS["P"]

    @formatting.register_unit_format("P")
    def format_pretty(unit, registry, **options):
        return formatting.formatter(
            unit.items(),
            as_ratio=False,
            single_denominator=False,
            product_fmt="⋅",
            division_fmt="/",
            power_fmt="{}{}",
            parentheses_fmt="({})",
            exp_call=formatting._pretty_fmt_exponent,
            **options,
        )

    @formatting.register_unit_format("K")
    def format_spectrochempy_compact(unit, registry, **options):
        return formatting.formatter(
            unit.items(),
            as_ratio=False,
            single_denominator=False,
            product_fmt=" ",
            division_fmt="/",
            power_fmt="{}^{}",
            parentheses_fmt=r"({})",
            **options,
        )

    del formatting._FORMATTERS["L"]

    @formatting.register_unit_format("L")
    def format_latex(unit, registry, **options):
        preprocessed = {
            r"\mathrm{{{}}}".format(u.replace("_", r"\_")): p for u, p in unit.items()
        }
        formatted = formatting.formatter(
            preprocessed.items(),
            as_ratio=False,
            single_denominator=True,
            product_fmt=r" \cdot ",
            division_fmt=r"\frac[{}][{}]",
            power_fmt="{}^[{}]",
            parentheses_fmt=r"\left({}\right)",
            **options,
        )
        return formatted.replace("[", "{").replace("]", "}")

    del formatting._FORMATTERS["H"]

    @formatting.register_unit_format("H")
    def format_html(unit, registry, **options):
        return formatting.formatter(
            unit.items(),
            as_ratio=False,
            single_denominator=True,
            product_fmt=r"⋅",
            division_fmt=r"{}/{}",
            power_fmt=r"{}<sup>{}</sup>",
            parentheses_fmt=r"({})",
            **options,
        )

    del formatting._FORMATTERS["D"]

    @formatting.register_unit_format("D")
    def format_default(unit, registry, **options):
        return formatting.formatter(
            unit.items(),
            as_ratio=False,
            single_denominator=False,
            product_fmt="*",
            division_fmt="/",
            power_fmt="{}^{}",
            parentheses_fmt=r"({})",
            **options,
        )

    del formatting._FORMATTERS["C"]

    @formatting.register_unit_format("C")
    def format_compact(unit, registry, **options):
        return formatting.formatter(
            unit.items(),
            as_ratio=False,
            single_denominator=False,
            product_fmt="*",
            division_fmt="/",
            power_fmt="{}**{}",
            parentheses_fmt=r"({})",
            **options,
        )

    def _repr_html_(cls):
        p = cls.__format__("~H")
        # attempt to solve a display problem in notebook (recent version of pint
        # have a strange way to handle HTML. For me it doesn't work
        return p.replace(r"\[", "").replace(r"\]", "").replace(r"\ ", " ")

    Quantity._repr_html_ = _repr_html_
    Quantity._repr_latex_ = lambda cls: "$" + cls.__format__("~L") + "$"

    # TODO: work on this latex format

    Unit.scaling = property(
        lambda u: u._REGISTRY.Quantity(1.0, u._units).to_base_units().magnitude,
    )

    # --------------------------------------------------------------------------------------
    def __format__(self, spec):  # noqa: N807
        # modify Pint unit __format__

        spec = formatting.extract_custom_flags(spec or self.default_format)
        if "~" in spec:
            if not self._units:
                return ""

            # Spectrochempy
            if self.dimensionless and "absorbance" not in self._units:
                if self._units == "ppm":
                    units = UnitsContainer({"ppm": 1})
                elif self._units in ["percent", "transmittance"]:
                    units = UnitsContainer({"%": 1})
                elif self._units == "weight_percent":
                    units = UnitsContainer({"wt.%": 1})
                elif self._units == "radian":
                    units = UnitsContainer({"rad": 1})
                elif self._units == "degree":
                    units = UnitsContainer({"deg": 1})
                # elif self._units == 'absorbance':
                #    units = UnitsContainer({'a.u.': 1})
                elif abs(self.scaling - 1.0) < 1.0e-10:
                    units = UnitsContainer({"": 1})
                else:
                    units = UnitsContainer(
                        {f"scaled-dimensionless ({self.scaling:.2g})": 1},
                    )
            else:
                units = UnitsContainer(
                    {
                        self._REGISTRY._get_symbol(key): value
                        for key, value in self._units.items()
                    },
                )
            spec = spec.replace("~", "")
        else:
            units = self._units

        return formatting.format_unit(units, spec, registry=self._REGISTRY)

    Unit.__format__ = __format__

    if globals().get("U_", None) is None:
        # filename = resource_filename(PKG, 'spectrochempy.txt')
        U_ = UnitRegistry(on_redefinition="ignore")  # filename)

        U_.define(
            "__wrapped__ = 1",
        )  # <- hack to avoid an error with pytest (doctest activated)
        U_.define("@alias point = count")
        U_.define("transmittance = 1. / 100.")
        U_.define("absolute_transmittance = 1.")
        U_.define("absorbance = 1. = a.u.")
        U_.define("Kubelka_Munk = 1. = K.M.")
        U_.define("ppm = 1. = ppm")

        if pint_version < 20:
            U_.define(UnitDefinition("percent", "pct", (), ScaleConverter(1 / 100.0)))
            U_.define(
                UnitDefinition(
                    "weight_percent",
                    "wt_pct",
                    (),
                    ScaleConverter(1 / 100.0),
                ),
            )
        else:
            U_.define(
                UnitDefinition(
                    "percent",
                    "pct",
                    (),
                    ScaleConverter(1 / 100.0),
                    UnitsContainer(),
                ),
            )
            U_.define(
                UnitDefinition(
                    "weight_percent",
                    "wt_pct",
                    (),
                    ScaleConverter(1 / 100.0),
                    UnitsContainer(),
                ),
            )

        U_.default_format = "~P"
        Q_ = U_.Quantity
        Q_.default_format = "~P"

        set_application_registry(U_)
        del UnitRegistry  # to avoid importing it

    else:
        warnings.warn(
            "Unit registry was already set up. Bypassed the new loading",
            stacklevel=2,
        )

    U_.enable_contexts("spectroscopy", "boltzmann", "chemistry")

    # set alias for units and uncertainties
    # --------------------------------------------------------------------------------------
    ur = U_
    Quantity = Q_

    # utilities

    # Context for NMR
    # --------------------------------------------------------------------------------------
    def set_nmr_context(larmor):
        """
        Set a NMR context relative to the given Larmor frequency.

        Parameters
        ----------
        larmor : `Quantity` or `float`
            The Larmor frequency of the current nucleus.
            If it is not a quantity it is assumed to be given in MHz.

        Examples
        --------
        First we set the NMR context,

        >>> from spectrochempy.core.units import ur, set_nmr_context
        >>>
        >>> set_nmr_context(104.3 * ur.MHz)

        then, we can use the context as follow

        >>> fhz = 10000 * ur.Hz
        >>> with ur.context('nmr'):
        ...     fppm = fhz.to('ppm')
        >>> print("{:~.3f}".format(fppm))
        95.877 ppm

        or in the opposite direction

        >>> with ur.context('nmr'):
        ...     fhz = fppm.to('kHz')
        >>> print("{:~.3f}".format(fhz))
        10.000 kHz

        Now we update the context :

        >>> with ur.context('nmr', larmor=100. * ur.MHz):
        ...     fppm = fhz.to('ppm')
        >>> print("{:~.3f}".format(fppm))
        100.000 ppm

        >>> set_nmr_context(75 * ur.MHz)
        >>> fhz = 10000 * ur.Hz
        >>> with ur.context('nmr'):
        ...     fppm = fhz.to('ppm')
        >>> print("{:~.3f}".format(fppm))
        133.333 ppm

        """
        if not isinstance(larmor, U_.Quantity):
            larmor = larmor * U_.MHz

        if "nmr" not in list(ur._contexts.keys()):
            c = Context("nmr", defaults={"larmor": larmor})

            c.add_transformation(
                "[]",
                "[frequency]",
                lambda ur, x, **kwargs: x * kwargs.get("larmor") / 1.0e6,
            )
            c.add_transformation(
                "[frequency]",
                "[]",
                lambda U_, x, **kwargs: x * 1.0e6 / kwargs.get("larmor"),
            )
            U_.add_context(c)

        else:
            c = U_._contexts["nmr"]
            c.defaults["larmor"] = larmor

else:  # pint version >= 24
    from functools import wraps

    from pint import Context
    from pint import DimensionalityError
    from pint import Unit
    from pint import UnitRegistry
    from pint import set_application_registry

    # utilities
    from pint.delegates.formatter._compound_unit_helpers import localize_per
    from pint.delegates.formatter._compound_unit_helpers import prepare_compount_unit
    from pint.delegates.formatter._format_helpers import formatter
    from pint.delegates.formatter._format_helpers import pretty_fmt_exponent

    # formatters to be  subclassed
    from pint.delegates.formatter.full import FullFormatter
    from pint.delegates.formatter.html import HTMLFormatter
    from pint.delegates.formatter.latex import LatexFormatter
    from pint.delegates.formatter.latex import latex_escape
    from pint.delegates.formatter.plain import CompactFormatter
    from pint.delegates.formatter.plain import DefaultFormatter
    from pint.delegates.formatter.plain import PrettyFormatter
    from pint.facets.plain import ScaleConverter
    from pint.facets.plain import UnitDefinition
    from pint.util import UnitsContainer

    ####################################################################################
    # SpectroChemPy specific formatters
    # ##################################################################################
    class ScpDefaultFormatter(DefaultFormatter):
        """Subclasses the DefaultFormatter to provide a specific formatting for SpectroChemPy."""

        def format_unit(self, unit, uspec, sort_func, **babel_kwds) -> str:
            numerator, denominator = prepare_compount_unit(
                unit,
                uspec,
                sort_func=sort_func,
                **babel_kwds,
                registry=self._registry,
            )

            return formatter(
                numerator,
                denominator,
                as_ratio=False,
                single_denominator=False,
                product_fmt=" ",
                division_fmt="/",
                power_fmt="{}^{}",
                parentheses_fmt=r"({})",
            )

    class ScpCompactFormatter(CompactFormatter):
        """Subclasses the CompactFormatter to provide a specific formatting for SpectroChemPy."""

        def format_unit(self, unit, uspec, sort_func, **babel_kwds) -> str:
            numerator, denominator = prepare_compount_unit(
                unit,
                uspec,
                sort_func=sort_func,
                **babel_kwds,
                registry=self._registry,
            )

            return formatter(
                numerator,
                denominator,
                as_ratio=False,
                single_denominator=False,
                product_fmt="*",
                division_fmt="/",
                power_fmt="{}**{}",
                parentheses_fmt=r"({})",
            )

    class ScpPrettyFormatter(PrettyFormatter):
        """Subclasses the PretyFormatter to provide a specific formatting for SpectroChemPy."""

        def format_unit(self, unit, uspec, sort_func, **babel_kwds) -> str:
            numerator, denominator = prepare_compount_unit(
                unit,
                uspec,
                sort_func=sort_func,
                **babel_kwds,
                registry=self._registry,
            )

            return formatter(
                numerator,
                denominator,
                as_ratio=False,
                single_denominator=False,
                product_fmt="⋅",
                division_fmt="/",
                power_fmt="{}{}",
                parentheses_fmt=r"({})",
                exp_call=pretty_fmt_exponent,
            )

    class ScpHTMLFormatter(HTMLFormatter):
        """Subclasses the HTMLFormatter to provide a specific formatting for SpectroChemPy."""

        def format_unit(self, unit, uspec, sort_func, **babel_kwds) -> str:
            numerator, denominator = prepare_compount_unit(
                unit,
                uspec,
                sort_func=sort_func,
                **babel_kwds,
                registry=self._registry,
            )

            if babel_kwds.get("locale"):
                length = babel_kwds.get("length") or (
                    "short" if "~" in uspec else "long"
                )
                division_fmt = localize_per(length, babel_kwds.get("locale"), "{}/{}")
            else:
                division_fmt = "{}/{}"

            return formatter(
                numerator,
                denominator,
                as_ratio=False,
                single_denominator=True,
                product_fmt=r"⋅",
                division_fmt=division_fmt,
                power_fmt=r"{}<sup>{}</sup>",
                parentheses_fmt=r"({})",
            )

    class ScpLatexFormatter(LatexFormatter):
        """Subclasses the LatexFormatter to provide a specific formatting for SpectroChemPy."""

        def format_unit(self, unit, uspec, sort_func, **babel_kwds) -> str:
            numerator, denominator = prepare_compount_unit(
                unit,
                uspec,
                sort_func=sort_func,
                **babel_kwds,
                registry=self._registry,
            )

            numerator = ((rf"\mathrm{{{latex_escape(u)}}}", p) for u, p in numerator)
            denominator = (
                (rf"\mathrm{{{latex_escape(u)}}}", p) for u, p in denominator
            )

            formatted = formatter(
                numerator,
                denominator,
                as_ratio=False,
                single_denominator=True,
                product_fmt=r" \cdot ",
                division_fmt=r"\frac[{}][{}]",
                power_fmt="{}^[{}]",
                parentheses_fmt=r"\left({}\right)",
            )

            return formatted.replace("[", "{").replace("]", "}")

    class ScpFullFormatter(FullFormatter):
        """Subclasses the Formatter to provide a specific formatting for SpectroChemPy."""

        default_format: str = "~P"

        def __init__(self, registry):
            super().__init__(registry)

            self._formatters = {}
            self._formatters["D"] = ScpDefaultFormatter(registry)
            self._formatters["C"] = ScpCompactFormatter(registry)
            self._formatters["P"] = ScpPrettyFormatter(registry)
            self._formatters["H"] = ScpHTMLFormatter(registry)
            self._formatters["L"] = ScpLatexFormatter(registry)

    ####################################################################################
    # Spectrochempy UnitRegistry
    ####################################################################################

    if globals().get("ur", None) is None:
        ur = UnitRegistry(on_redefinition="ignore")

        ur = UnitRegistry()
        ur.formatter = ScpFullFormatter(ur)
        ur.formatter._registry = ur

        Quantity = ur.Quantity

        ur.define(
            "__wrapped__ = 1",
        )  # <- hack to avoid an error with pytest (doctest activated)
        ur.define("@alias point = count")
        ur.define("transmittance = 1. / 100. = %")
        ur.define("absolute_transmittance = 1.")
        ur.define("absorbance = 1. = a.u.")
        ur.define("Kubelka_Munk = 1. = K.M.")
        ur.define("ppm = 1. = ppm")

        set_application_registry(ur)
        del UnitRegistry  # to avoid importing it

    else:
        warnings.warn(
            "Unit registry was already set up. Bypassed the new loading",
            stacklevel=2,
        )

    ur.enable_contexts("spectroscopy", "boltzmann", "chemistry")

    ###################################################################################
    # Context for NMR
    ###################################################################################
[docs] def set_nmr_context(larmor): """ Set a NMR context relative to the given Larmor frequency. Parameters ---------- larmor : `Quantity` or `float` The Larmor frequency of the current nucleus. If it is not a quantity it is assumed to be given in MHz. Examples -------- First we set the NMR context, >>> from spectrochempy.core.units import ur, set_nmr_context >>> >>> set_nmr_context(104.3 * ur.MHz) then, we can use the context as follow >>> fhz = 10000 * ur.Hz >>> with ur.context('nmr'): ... fppm = fhz.to('ppm') >>> print("{:~.3f}".format(fppm)) 95.877 ppm or in the opposite direction >>> with ur.context('nmr'): ... fhz = fppm.to('kHz') >>> print("{:~.3f}".format(fhz)) 10.000 kHz Now we update the context : >>> with ur.context('nmr', larmor=100. * ur.MHz): ... fppm = fhz.to('ppm') >>> print("{:~.3f}".format(fppm)) 100.000 ppm >>> set_nmr_context(75 * ur.MHz) >>> fhz = 10000 * ur.Hz >>> with ur.context('nmr'): ... fppm = fhz.to('ppm') >>> print("{:~.3f}".format(fppm)) 133.333 ppm """ if not isinstance(larmor, ur.Quantity): larmor = larmor * ur.MHz if "nmr" not in list(ur._contexts.keys()): c = Context("nmr", defaults={"larmor": larmor}) c.add_transformation( "[]", "[frequency]", lambda ur, x, **kwargs: x * kwargs.get("larmor") / 1.0e6, ) c.add_transformation( "[frequency]", "[]", lambda U_, x, **kwargs: x * 1.0e6 / kwargs.get("larmor"), ) ur.add_context(c) else: c = ur._contexts["nmr"] c.defaults["larmor"] = larmor
######################################################################################## # utilities ######################################################################################## def remove_args_units(func): """Remove units of arguments of a function.""" def _remove_units(val): if isinstance(val, Quantity): val = val.m elif isinstance(val, list | tuple): val = type(val)([_remove_units(v) for v in val]) return val @wraps(func) def new_func(*args, **kwargs): args = tuple([_remove_units(arg) for arg in args]) kwargs = {key: _remove_units(val) for key, val in kwargs.items()} return func(*args, **kwargs) return new_func