Source code for spectrochempy.analysis.curvefitting._models

# ======================================================================================
# Copyright (©) 2015-2025 LCS - Laboratoire Catalyse et Spectrochimie, Caen, France.
# CeCILL-B FREE SOFTWARE LICENSE AGREEMENT
# See full LICENSE agreement in the root directory.
# ======================================================================================
"""Module holding the definitions of all the various models."""

from functools import wraps

import numpy as np

from spectrochempy.core.dataset.coord import Coord
from spectrochempy.core.dataset.nddataset import NDDataset
from spectrochempy.core.units import Quantity

__all__ = [
    "polynomialbaseline",
    "gaussianmodel",
    "lorentzianmodel",
    "voigtmodel",
    "asymmetricvoigtmodel",
    "sigmoidmodel",
]


def make_units_compatibility(func):
    """Take into account the input features (units, type...)."""

    def _convert_to_units(arg, x_units):
        if isinstance(arg, Quantity):
            arg.ito(x_units)  # eventually convert units and rescale
        # set units to those of x
        elif x_units is not None:
            arg = arg * x_units
        else:
            # do not take into account unit of arg
            return arg
        return arg.magnitude

    @wraps(func)
    def wrapper(cls, xinput, *args, **kwargs):
        returntype = "ndarray"
        x = xinput.copy()

        x_units = None
        if hasattr(xinput, "units"):
            x_units = xinput.units
            if isinstance(xinput, Coord):
                x = xinput.data
                returntype = "NDDataset"
            else:
                x = xinput.m

        # get args or their equivalent in kwargs and eventually convert units.
        newargs = []

        for index, param in enumerate(cls.args):
            newargs.append(kwargs.get(param, args[index] if len(args) > index else 0))

        for index, arg in enumerate(newargs):
            # adapt units
            if cls.args[index] in ["width", "pos"]:
                # implicit units: those of x else rescale
                newargs[index] = _convert_to_units(arg, x_units)

        ampl_units = None
        if hasattr(newargs[0], "units"):
            ampl_units = newargs[0].units
            newargs[0] = newargs[0].m

        _data = func(cls, x, *newargs)

        if returntype == "NDDataset":
            res = NDDataset(_data, units=ampl_units)
            res.x = Coord(xinput)
            res.name = cls.__class__.__name__.split("model")[0]
            res.title = "intensity"

        else:
            res = _data
            if ampl_units:
                res = res * ampl_units

        return res

    return wrapper


############
#          #
#    1D    #
#          #
############


# ======================================================================================
# PolynomialBaseline
# ======================================================================================
[docs] class polynomialbaseline: r""" Arbitrary-degree polynomial (degree limited to 10, however). As a linear baseline is automatically calculated, this polynom is always of greater or equal to order 2 (parabolic function). .. math:: f(x) = ampl * \sum_{i=2}^{max} c_i*x^i """ type = "1D" args = ["ampl"] args.extend([f"c_{i}" for i in range(2, 11)]) script = """ MODEL: baseline%(id)d\nshape: polynomialbaseline # This polynom starts at the order 2 # as a linear baseline is additionally fitted automatically # parameters must be in the form c_i where i is an integer as shown below $ ampl: %(scale).3g, 0.0, None $ c_2: 1.0, None, None * c_3: 0.0, None, None * c_4: 0.0, None, None # etc... """ @make_units_compatibility def f(self, x, ampl, *c_, **kwargs): c = [0.0, 0.0] c.extend(c_) return ampl * np.polyval(np.array(tuple(c))[::-1], x - x[int(x.size / 2)])
# #=============================================================================== # # Gaussian2DModel # #=============================================================================== # class gaussian2dmodel(object): # r""" # Two dimensional Gaussian model (*not* normalized - peak value is 1). # # .. math:: # A e^{\frac{-(x-\iso_x)^2}{2 \gb_x^2}} e^{\frac{-(y-\iso_y)^2}{2 \gb_y^2}} # # """ # args = ['amp','gbx','gby','posx','posy'] # def f(self, xy, amp, gbx, gby, posx, posy, **kargs): # gbx = float(gbx) # gby = float(gby) # x,y = xy # xo = x-posx # xdenom = 2*gbx*gbx # yo = y-posy # ydenom = 2*gby*gby # return amp*np.exp(-xo*xo/xdenom-yo*yo/ydenom) # ====================================================================================== # ====================================================================================== # GaussianModel # ======================================================================================
[docs] class gaussianmodel: r""" Normalized 1D gaussian function. .. math:: f(x) = \frac{ampl}{\sqrt{2 \pi \sigma^2} } \exp({\frac{-(x-pos)^2}{2 \sigma^2}}) where :math:`\sigma = \frac{width}{2.3548}` . """ type = "1D" args = ["ampl", "pos", "width"] script = """ MODEL: line%(id)d\nshape: gaussianmodel $ ampl: %(ampl).3f, 0.0, None $ width: %(width).3f, 0.0, None $ pos: %(pos).3f, %(poslb).3f, %(poshb).3f """ @make_units_compatibility def f(self, x, ampl, pos, width, **kwargs): gb = width / 2.3548 tsq = (x - pos) * 2**-0.5 / gb w = np.exp(-tsq * tsq) * (2 * np.pi) ** -0.5 / gb w = w * abs(x[1] - x[0]) return ampl * w
# ====================================================================================== # LorentzianModel # ======================================================================================
[docs] class lorentzianmodel: r""" A standard Lorentzian function (also known as the Cauchy distribution). .. math:: f(x) = \frac{ampl * \lambda}{\pi [(x-pos)^2+ \lambda^2]} where :math:`\lambda = \frac{width}{2}` . """ type = "1D" args = ["ampl", "pos", "width"] script = """ MODEL: line%(id)d\nshape: lorentzianmodel $ ampl: %(ampl).3f, 0.0, None $ width: %(width).3f, 0.0, None $ pos: %(pos).3f, %(poslb).3f, %(poshb).3f """ @make_units_compatibility def f(self, x, ampl, pos, width, **kargs): lb = width / 2.0 w = lb / np.pi / (x * x - 2 * x * pos + pos * pos + lb * lb) w = w * abs(x[1] - x[0]) return ampl * w
# ====================================================================================== # VoigtModel # ======================================================================================
[docs] class voigtmodel: """ A Voigt model constructed as the convolution of a :class:`GaussianModel` and a :class:`LorentzianModel` . Commonly used for spectral line fitting. """ type = "1D" args = ["ampl", "pos", "width", "ratio"] script = """ MODEL: line%(id)d\nshape: voigtmodel $ ampl: %(ampl).3f, 0.0, None $ width: %(width).3f, 0.0, None $ pos: %(pos).3f, %(poslb).3f, %(poshb).3f $ ratio: 0.1, 0.0, 1.0 """ # @make_units_compatibility # def f(self, x, ampl, pos, width, ratio, **kargs): # from scipy.special import wofz # # gb = ratio * width / 2.3548 # lb = (1.0 - ratio) * width / 2.0 # if ratio < 1.0e-16: # return lorentzianmodel().f(x, ampl, pos, lb * 2.0, **kargs) # else: # w = wofz(((x - pos) + 1.0j * lb) * 2 ** -0.5 / gb) # w = w.real * (2.0 * np.pi) ** -0.5 / gb # w = w * abs(x[1] - x[0]) # return ampl * w @staticmethod def f(x, ampl, pos, width, ratio, **kargs): return asymmetricvoigtmodel().f(x, ampl, pos, width, ratio, asym=0.0)
# ====================================================================================== # Asymmetric Voigt Model # ======================================================================================
[docs] class asymmetricvoigtmodel: """ An asymmetric Voigt model. A. L. Stancik and E. B. Brauns, Vibrational Spectroscopy, 2008, 47, 66-69. """ type = "1D" args = ["ampl", "pos", "width", "ratio", "asym"] script = """ MODEL: line%(id)d\nshape: voigtmodel $ ampl: %(ampl).3f, 0.0, None $ width: %(width).3f, 0.0, None $ pos: %(pos).3f, %(poslb).3f, %(poshb).3f $ ratio: 0.1, 0.0, 1.0 $ asym: 0.1, 0.0, 1.0 """ @make_units_compatibility def f(self, x, ampl, pos, width, ratio, asym, **kargs): from scipy.special import wofz g = width if asym > 0.0: # sigmoid variation of the width g = 2.0 * sigmoidmodel().f(x, width, pos, asym) gb = ratio * g / 2.3548 lb = (1.0 - ratio) * g / 2.0 if ratio < 1.0e-16: return lorentzianmodel().f(x, ampl, pos, lb * 2.0, **kargs) w = wofz(((x - pos) + 1.0j * lb) * 2**-0.5 / gb) w = w.real * (2.0 * np.pi) ** -0.5 / gb w = w * abs(x[1] - x[0]) return ampl * w
# ====================================================================================== # Sigmoid Model # ======================================================================================
[docs] class sigmoidmodel: r""" A Sigmoid function. .. math:: f(x) = \frac{1.}{1 + \exp(\lambda (x-pos))} """ type = "1D" args = ["ampl", "pos", "asym"] script = """ MODEL: line%(id)d\nshape: sigmoidmodel $ ampl: %(ampl).3f, 0.0, None $ pos: %(pos).3f, %(poslb).3f, %(poshb).3f $ asym: %(asym).3f, 0.0, None """ @make_units_compatibility def f(self, x, ampl, pos, asym, **kargs): w = 1.0 / (1.0 + np.exp(asym * (x - pos) / ampl)) return ampl * w
# ====================================================================================== # User defined model # ====================================================================================== class usermodel: """Base class for user defined models.""" type = "1D" args = [] @staticmethod def f(): raise NotImplementedError("This is a base class for user defined models")