diff options
Diffstat (limited to 'src/silx/math/fit')
30 files changed, 10037 insertions, 0 deletions
diff --git a/src/silx/math/fit/__init__.py b/src/silx/math/fit/__init__.py new file mode 100644 index 0000000..29e6a9e --- /dev/null +++ b/src/silx/math/fit/__init__.py @@ -0,0 +1,39 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "22/06/2016" + + +from .leastsq import leastsq, chisq_alpha_beta +from .leastsq import \ + CFREE, CPOSITIVE, CQUOTED, CFIXED, \ + CFACTOR, CDELTA, CSUM + +from .functions import * +from .filters import * +from .peaks import peak_search, guess_fwhm +from .fitmanager import FitManager +from .fittheory import FitTheory diff --git a/src/silx/math/fit/bgtheories.py b/src/silx/math/fit/bgtheories.py new file mode 100644 index 0000000..631c43e --- /dev/null +++ b/src/silx/math/fit/bgtheories.py @@ -0,0 +1,440 @@ +# coding: utf-8 +#/*########################################################################## +# +# Copyright (c) 2004-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +########################################################################### */ +"""This modules defines a set of background model functions and associated +estimation functions in a format that can be imported into a +:class:`silx.math.fit.FitManager` object. + +A background function is a function that you want to add to a regular fit +function prior to fitting the sum of both functions. This is useful, for +instance, if you need to fit multiple gaussian peaks in an array of +measured data points when the measurement is polluted by a background signal. + +The models include common background models such as a constant value or a +linear background. + +It also includes background computation filters - *strip* and *snip* - that +can extract a more complex low-curvature background signal from a signal with +peaks having higher curvatures. + +The source code of this module can serve as a template for defining your +own fit background theories. The minimal skeleton of such a theory definition +file is:: + + from silx.math.fit.fittheory import FitTheory + + def bgfunction1(x, y0, …): + bg_signal = … + return bg_signal + + def estimation_function1(x, y): + … + estimated_params = … + constraints = … + return estimated_params, constraints + + THEORY = { + 'bg_theory_name1': FitTheory( + description='Description of theory 1', + function=bgfunction1, + parameters=('param name 1', 'param name 2', …), + estimate=estimation_function1, + configure=configuration_function1, + derivative=derivative_function1, + is_background=True), + 'theory_name_2': …, + } +""" +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "16/01/2017" + +from collections import OrderedDict +import numpy +from silx.math.fit.filters import strip, snip1d,\ + savitsky_golay +from silx.math.fit.fittheory import FitTheory + +CONFIG = { + "SmoothingFlag": False, + "SmoothingWidth": 5, + "AnchorsFlag": False, + "AnchorsList": [], + "StripWidth": 2, + "StripIterations": 5000, + "StripThresholdFactor": 1.0, + "SnipWidth": 16, + "EstimatePolyOnStrip": True +} + +# to avoid costly computations when parameters stay the same +_BG_STRIP_OLDY = numpy.array([]) +_BG_STRIP_OLDPARS = [0, 0] +_BG_STRIP_OLDBG = numpy.array([]) + +_BG_SNIP_OLDY = numpy.array([]) +_BG_SNIP_OLDWIDTH = None +_BG_SNIP_OLDBG = numpy.array([]) + + +_BG_OLD_ANCHORS = [] +_BG_OLD_ANCHORS_FLAG = None + +_BG_SMOOTH_OLDWIDTH = None +_BG_SMOOTH_OLDFLAG = None + + +def _convert_anchors_to_indices(x): + """Anchors stored in CONFIG["AnchorsList"] are abscissa. + Convert then to indices (take first index where x >= anchor), + then return the list of indices. + + :param x: Original array of abscissa + :return: List of indices of anchors in x array. + If CONFIG['AnchorsFlag'] is False or None, or if the list + of indices is empty, return None. + """ + # convert anchor X abscissa to index + if CONFIG['AnchorsFlag'] and CONFIG['AnchorsList'] is not None: + anchors_indices = [] + for anchor_x in CONFIG['AnchorsList']: + if anchor_x <= x[0]: + continue + # take the first index where x > anchor_x + indices = numpy.nonzero(x >= anchor_x)[0] + if len(indices): + anchors_indices.append(min(indices)) + if not len(anchors_indices): + anchors_indices = None + else: + anchors_indices = None + + return anchors_indices + + +def strip_bg(x, y0, width, niter): + """Extract and return the strip bg from y0. + + Use anchors coordinates in CONFIG["AnchorsList"] if flag + CONFIG["AnchorsFlag"] is True. Convert anchors from x coordinate + to array index prior to passing it to silx.math.fit.filters.strip + + :param x: Abscissa array + :param x: Ordinate array (data values at x positions) + :param width: strip width + :param niter: strip niter + """ + global _BG_STRIP_OLDY + global _BG_STRIP_OLDPARS + global _BG_STRIP_OLDBG + global _BG_SMOOTH_OLDWIDTH + global _BG_SMOOTH_OLDFLAG + global _BG_OLD_ANCHORS + global _BG_OLD_ANCHORS_FLAG + + parameters_changed =\ + _BG_STRIP_OLDPARS != [width, niter] or\ + _BG_SMOOTH_OLDWIDTH != CONFIG["SmoothingWidth"] or\ + _BG_SMOOTH_OLDFLAG != CONFIG["SmoothingFlag"] or\ + _BG_OLD_ANCHORS_FLAG != CONFIG["AnchorsFlag"] or\ + _BG_OLD_ANCHORS != CONFIG["AnchorsList"] + + # same parameters + if not parameters_changed: + # same data + if numpy.array_equal(_BG_STRIP_OLDY, y0): + # same result + return _BG_STRIP_OLDBG + + _BG_STRIP_OLDY = y0 + _BG_STRIP_OLDPARS = [width, niter] + _BG_SMOOTH_OLDWIDTH = CONFIG["SmoothingWidth"] + _BG_SMOOTH_OLDFLAG = CONFIG["SmoothingFlag"] + _BG_OLD_ANCHORS = CONFIG["AnchorsList"] + _BG_OLD_ANCHORS_FLAG = CONFIG["AnchorsFlag"] + + y1 = savitsky_golay(y0, CONFIG["SmoothingWidth"]) if CONFIG["SmoothingFlag"] else y0 + + anchors_indices = _convert_anchors_to_indices(x) + + background = strip(y1, + w=width, + niterations=niter, + factor=CONFIG["StripThresholdFactor"], + anchors=anchors_indices) + + _BG_STRIP_OLDBG = background + + return background + + +def snip_bg(x, y0, width): + """Compute the snip bg for y0""" + global _BG_SNIP_OLDY + global _BG_SNIP_OLDWIDTH + global _BG_SNIP_OLDBG + global _BG_SMOOTH_OLDWIDTH + global _BG_SMOOTH_OLDFLAG + global _BG_OLD_ANCHORS + global _BG_OLD_ANCHORS_FLAG + + parameters_changed =\ + _BG_SNIP_OLDWIDTH != width or\ + _BG_SMOOTH_OLDWIDTH != CONFIG["SmoothingWidth"] or\ + _BG_SMOOTH_OLDFLAG != CONFIG["SmoothingFlag"] or\ + _BG_OLD_ANCHORS_FLAG != CONFIG["AnchorsFlag"] or\ + _BG_OLD_ANCHORS != CONFIG["AnchorsList"] + + # same parameters + if not parameters_changed: + # same data + if numpy.sum(_BG_SNIP_OLDY == y0) == len(y0): + # same result + return _BG_SNIP_OLDBG + + _BG_SNIP_OLDY = y0 + _BG_SNIP_OLDWIDTH = width + _BG_SMOOTH_OLDWIDTH = CONFIG["SmoothingWidth"] + _BG_SMOOTH_OLDFLAG = CONFIG["SmoothingFlag"] + _BG_OLD_ANCHORS = CONFIG["AnchorsList"] + _BG_OLD_ANCHORS_FLAG = CONFIG["AnchorsFlag"] + + y1 = savitsky_golay(y0, CONFIG["SmoothingWidth"]) if CONFIG["SmoothingFlag"] else y0 + + anchors_indices = _convert_anchors_to_indices(x) + + if anchors_indices is None or not len(anchors_indices): + anchors_indices = [0, len(y1) - 1] + + background = numpy.zeros_like(y1) + previous_anchor = 0 + for anchor_index in anchors_indices: + if (anchor_index > previous_anchor) and (anchor_index < len(y1)): + background[previous_anchor:anchor_index] =\ + snip1d(y1[previous_anchor:anchor_index], + width) + previous_anchor = anchor_index + + if previous_anchor < len(y1): + background[previous_anchor:] = snip1d(y1[previous_anchor:], + width) + + _BG_SNIP_OLDBG = background + + return background + + +def estimate_linear(x, y): + """ + Estimate the linear parameters (constant, slope) of a y signal. + + Strip peaks, then perform a linear regression. + """ + bg = strip_bg(x, y, + width=CONFIG["StripWidth"], + niter=CONFIG["StripIterations"]) + n = float(len(bg)) + Sy = numpy.sum(bg) + Sx = float(numpy.sum(x)) + Sxx = float(numpy.sum(x * x)) + Sxy = float(numpy.sum(x * bg)) + + deno = n * Sxx - (Sx * Sx) + if deno != 0: + bg = (Sxx * Sy - Sx * Sxy) / deno + slope = (n * Sxy - Sx * Sy) / deno + else: + bg = 0.0 + slope = 0.0 + estimated_par = [bg, slope] + # code = 0: FREE + constraints = [[0, 0, 0], [0, 0, 0]] + return estimated_par, constraints + + +def estimate_strip(x, y): + """Estimation function for strip parameters. + + Return parameters as defined in CONFIG dict, + set constraints to FIXED. + """ + estimated_par = [CONFIG["StripWidth"], + CONFIG["StripIterations"]] + constraints = numpy.zeros((len(estimated_par), 3), numpy.float64) + # code = 3: FIXED + constraints[0][0] = 3 + constraints[1][0] = 3 + return estimated_par, constraints + + +def estimate_snip(x, y): + """Estimation function for snip parameters. + + Return parameters as defined in CONFIG dict, + set constraints to FIXED. + """ + estimated_par = [CONFIG["SnipWidth"]] + constraints = numpy.zeros((len(estimated_par), 3), numpy.float64) + # code = 3: FIXED + constraints[0][0] = 3 + return estimated_par, constraints + + +def poly(x, y, *pars): + """Order n polynomial. + The order of the polynomial is defined by the number of + coefficients (``*pars``). + + """ + p = numpy.poly1d(pars) + return p(x) + + +def estimate_poly(x, y, deg=2): + """Estimate polynomial coefficients. + + """ + # extract bg signal with strip, to estimate polynomial on background + if CONFIG["EstimatePolyOnStrip"]: + y = strip_bg(x, y, + CONFIG["StripWidth"], + CONFIG["StripIterations"]) + pcoeffs = numpy.polyfit(x, y, deg) + cons = numpy.zeros((deg + 1, 3), numpy.float64) + return pcoeffs, cons + + +def estimate_quadratic_poly(x, y): + """Estimate quadratic polynomial coefficients. + """ + return estimate_poly(x, y, deg=2) + + +def estimate_cubic_poly(x, y): + """Estimate cubic polynomial coefficients. + """ + return estimate_poly(x, y, deg=3) + + +def estimate_quartic_poly(x, y): + """Estimate degree 4 polynomial coefficients. + """ + return estimate_poly(x, y, deg=4) + + +def estimate_quintic_poly(x, y): + """Estimate degree 5 polynomial coefficients. + """ + return estimate_poly(x, y, deg=5) + + +def configure(**kw): + """Update the CONFIG dict + """ + # inspect **kw to find known keys, update them in CONFIG + for key in CONFIG: + if key in kw: + CONFIG[key] = kw[key] + + return CONFIG + + +THEORY = OrderedDict( + (('No Background', + FitTheory( + description="No background function", + function=lambda x, y0: numpy.zeros_like(x), + parameters=[], + is_background=True)), + ('Constant', + FitTheory( + description='Constant background', + function=lambda x, y0, c: c * numpy.ones_like(x), + parameters=['Constant', ], + estimate=lambda x, y: ([min(y)], [[0, 0, 0]]), + is_background=True)), + ('Linear', + FitTheory( + description="Linear background, parameters 'Constant' and" + " 'Slope'", + function=lambda x, y0, a, b: a + b * x, + parameters=['Constant', 'Slope'], + estimate=estimate_linear, + configure=configure, + is_background=True)), + ('Strip', + FitTheory( + description="Compute background using a strip filter\n" + "Parameters 'StripWidth', 'StripIterations'", + function=strip_bg, + parameters=['StripWidth', 'StripIterations'], + estimate=estimate_strip, + configure=configure, + is_background=True)), + ('Snip', + FitTheory( + description="Compute background using a snip filter\n" + "Parameter 'SnipWidth'", + function=snip_bg, + parameters=['SnipWidth'], + estimate=estimate_snip, + configure=configure, + is_background=True)), + ('Degree 2 Polynomial', + FitTheory( + description="Quadratic polynomial background, Parameters " + "'a', 'b' and 'c'\ny = a*x^2 + b*x +c", + function=poly, + parameters=['a', 'b', 'c'], + estimate=estimate_quadratic_poly, + configure=configure, + is_background=True)), + ('Degree 3 Polynomial', + FitTheory( + description="Cubic polynomial background, Parameters " + "'a', 'b', 'c' and 'd'\n" + "y = a*x^3 + b*x^2 + c*x + d", + function=poly, + parameters=['a', 'b', 'c', 'd'], + estimate=estimate_cubic_poly, + configure=configure, + is_background=True)), + ('Degree 4 Polynomial', + FitTheory( + description="Quartic polynomial background\n" + "y = a*x^4 + b*x^3 + c*x^2 + d*x + e", + function=poly, + parameters=['a', 'b', 'c', 'd', 'e'], + estimate=estimate_quartic_poly, + configure=configure, + is_background=True)), + ('Degree 5 Polynomial', + FitTheory( + description="Quaintic polynomial background\n" + "y = a*x^5 + b*x^4 + c*x^3 + d*x^2 + e*x + f", + function=poly, + parameters=['a', 'b', 'c', 'd', 'e', 'f'], + estimate=estimate_quintic_poly, + configure=configure, + is_background=True)))) diff --git a/src/silx/math/fit/filters.pyx b/src/silx/math/fit/filters.pyx new file mode 100644 index 0000000..da1f6f5 --- /dev/null +++ b/src/silx/math/fit/filters.pyx @@ -0,0 +1,416 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +"""This module provides background extraction functions and smoothing +functions. These functions are extracted from PyMca module SpecFitFuns. + +Index of background extraction functions: +------------------------------------------ + + - :func:`strip` + - :func:`snip1d` + - :func:`snip2d` + - :func:`snip3d` + +Smoothing functions: +-------------------- + + - :func:`savitsky_golay` + - :func:`smooth1d` + - :func:`smooth2d` + - :func:`smooth3d` + +References: +----------- + +.. [Morhac97] Miroslav Morháč et al. + Background elimination methods for multidimensional coincidence γ-ray spectra. + Nucl. Instruments and Methods in Physics Research A401 (1997) 113-132. + https://doi.org/10.1016/S0168-9002(97)01023-1 + +.. [Ryan88] C.G. Ryan et al. + SNIP, a statistics-sensitive background treatment for the quantitative analysis of PIXE spectra in geoscience applications. + Nucl. Instruments and Methods in Physics Research B34 (1988) 396-402*. + https://doi.org/10.1016/0168-583X(88)90063-8 + +API documentation: +------------------- + +""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "15/05/2017" + +import logging +import numpy + +_logger = logging.getLogger(__name__) + +cimport cython +cimport silx.math.fit.filters_wrapper as filters_wrapper + + +def strip(data, w=1, niterations=1000, factor=1.0, anchors=None): + """Extract background from data using the strip algorithm, as explained at + http://pymca.sourceforge.net/stripbackground.html. + + In its simplest implementation it is just as an iterative procedure + depending on two parameters. These parameters are the strip background + width ``w``, and the number of iterations. At each iteration, if the + contents of channel ``i``, ``y(i)``, is above the average of the contents + of the channels at ``w`` channels of distance, ``y(i-w)`` and + ``y(i+w)``, ``y(i)`` is replaced by the average. + At the end of the process we are left with something that resembles a spectrum + in which the peaks have been stripped. + + :param data: Data array + :type data: numpy.ndarray + :param w: Strip width + :param niterations: number of iterations + :param factor: scaling factor applied to the average of ``y(i-w)`` and + ``y(i+w)`` before comparing to ``y(i)`` + :param anchors: Array of anchors, indices of points that will not be + modified during the stripping procedure. + :return: Data with peaks stripped away + """ + cdef: + double[::1] input_c + double[::1] output + long[::1] anchors_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__"): + raise TypeError("data must be a sequence (list, tuple) " + + "or a numpy array") + data_shape = (len(data), ) + else: + data_shape = data.shape + + input_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + output = numpy.empty(shape=(input_c.size,), + dtype=numpy.float64) + + if anchors is not None and len(anchors): + # numpy.int_ is the same as C long (http://docs.scipy.org/doc/numpy/user/basics.types.html) + anchors_c = numpy.array(anchors, + copy=False, + dtype=numpy.int_, + order='C') + len_anchors = anchors_c.size + else: + # Make a dummy length-1 array, because if I use shape=(0,) I get the error + # IndexError: Out of bounds on buffer access (axis 0) + anchors_c = numpy.empty(shape=(1,), + dtype=numpy.int_) + len_anchors = 0 + + + status = filters_wrapper.strip(&input_c[0], input_c.size, + factor, niterations, w, + &anchors_c[0], len_anchors, &output[0]) + + return numpy.asarray(output).reshape(data_shape) + + +def snip1d(data, snip_width): + """Estimate the baseline (background) of a 1D data vector by clipping peaks. + + Implementation of the algorithm SNIP in 1D is described in [Morhac97]_. + The original idea for 1D and the low-statistics-digital-filter (lsdf) comes + from [Ryan88]_. + + :param data: Data array, preferably 1D and of type *numpy.float64*. + Else, the data array will be flattened and converted to + *dtype=numpy.float64* prior to applying the snip filter. + :type data: numpy.ndarray + :param snip_width: Width of the snip operator, in number of samples. + A sample will be iteratively compared to it's neighbors up to a + distance of ``snip_width`` samples. This parameters has a direct + influence on the speed of the algorithm. + :type width: int + :return: Baseline of the input array, as an array of the same shape. + :rtype: numpy.ndarray + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__"): + raise TypeError("data must be a sequence (list, tuple) " + + "or a numpy array") + data_shape = (len(data), ) + else: + data_shape = data.shape + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.snip1d(&data_c[0], data_c.size, snip_width) + + return numpy.asarray(data_c).reshape(data_shape) + + +def snip2d(data, snip_width): + """Estimate the baseline (background) of a 2D data signal by clipping peaks. + + Implementation of the algorithm SNIP in 2D described in [Morhac97]_. + + :param data: 2D array + :type data: numpy.ndarray + :param width: Width of the snip operator, in number of samples. A wider + snip operator will result in a smoother result (lower frequency peaks + will be clipped), and a longer computation time. + :type width: int + :return: Baseline of the input array, as an array of the same shape. + :rtype: numpy.ndarray + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__") or not hasattr(data[0], "__len__"): + raise TypeError("data must be a 2D sequence (list, tuple) " + + "or a 2D numpy array") + nrows = len(data) + ncolumns = len(data[0]) + data_shape = (len(data), len(data[0])) + + else: + data_shape = data.shape + nrows = data_shape[0] + if len(data_shape) == 2: + ncolumns = data_shape[1] + else: + raise TypeError("data array must be 2-dimensional") + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.snip2d(&data_c[0], nrows, ncolumns, snip_width) + + return numpy.asarray(data_c).reshape(data_shape) + + +def snip3d(data, snip_width): + """Estimate the baseline (background) of a 3D data signal by clipping peaks. + + Implementation of the algorithm SNIP in 3D described in [Morhac97]_. + + :param data: 3D array + :type data: numpy.ndarray + :param width: Width of the snip operator, in number of samples. A wider + snip operator will result in a smoother result (lower frequency peaks + will be clipped), and a longer computation time. + :type width: int + + :return: Baseline of the input array, as an array of the same shape. + :rtype: numpy.ndarray + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__") or not hasattr(data[0], "__len__") or\ + not hasattr(data[0][0], "__len__"): + raise TypeError("data must be a 3D sequence (list, tuple) " + + "or a 3D numpy array") + nx = len(data) + ny = len(data[0]) + nz = len(data[0][0]) + data_shape = (len(data), len(data[0]), len(data[0][0])) + else: + data_shape = data.shape + nrows = data_shape[0] + if len(data_shape) == 3: + nx = data_shape[0] + ny = data_shape[1] + nz = data_shape[2] + else: + raise TypeError("data array must be 3-dimensional") + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.snip3d(&data_c[0], nx, ny, nz, snip_width) + + return numpy.asarray(data_c).reshape(data_shape) + + +def savitsky_golay(data, npoints=5): + """Smooth a curve using a Savitsky-Golay filter. + + :param data: Input data + :type data: 1D numpy array + :param npoints: Size of the smoothing operator in number of samples + Must be between 3 and 100. + :return: Smoothed data + """ + cdef: + double[::1] data_c + double[::1] output + + data_c = numpy.array(data, + dtype=numpy.float64, + order='C').reshape(-1) + + output = numpy.empty(shape=(data_c.size,), + dtype=numpy.float64) + + status = filters_wrapper.SavitskyGolay(&data_c[0], data_c.size, + npoints, &output[0]) + + if status: + _logger.error("Smoothing failed. Check that npoints is greater " + + "than 3 and smaller than 100.") + + return numpy.asarray(output).reshape(data.shape) + + +def smooth1d(data): + """Simple smoothing for 1D data. + + For a data array :math:`y` of length :math:`n`, the smoothed array + :math:`ys` is calculated as a weighted average of neighboring samples: + + :math:`ys_0 = 0.75 y_0 + 0.25 y_1` + + :math:`ys_i = 0.25 (y_{i-1} + 2 y_i + y_{i+1})` for :math:`0 < i < n-1` + + :math:`ys_{n-1} = 0.25 y_{n-2} + 0.75 y_{n-1}` + + + :param data: 1D data array + :type data: numpy.ndarray + :return: Smoothed data + :rtype: numpy.ndarray(dtype=numpy.float64) + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__"): + raise TypeError("data must be a sequence (list, tuple) " + + "or a numpy array") + data_shape = (len(data), ) + else: + data_shape = data.shape + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.smooth1d(&data_c[0], data_c.size) + + return numpy.asarray(data_c).reshape(data_shape) + + +def smooth2d(data): + """Simple smoothing for 2D data: + :func:`smooth1d` is applied succesively along both axis + + :param data: 2D data array + :type data: numpy.ndarray + :return: Smoothed data + :rtype: numpy.ndarray(dtype=numpy.float64) + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__") or not hasattr(data[0], "__len__"): + raise TypeError("data must be a 2D sequence (list, tuple) " + + "or a 2D numpy array") + nrows = len(data) + ncolumns = len(data[0]) + data_shape = (len(data), len(data[0])) + + else: + data_shape = data.shape + nrows = data_shape[0] + if len(data_shape) == 2: + ncolumns = data_shape[1] + else: + raise TypeError("data array must be 2-dimensional") + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.smooth2d(&data_c[0], nrows, ncolumns) + + return numpy.asarray(data_c).reshape(data_shape) + + +def smooth3d(data): + """Simple smoothing for 3D data: + :func:`smooth2d` is applied on each 2D slice of the data volume along all + 3 axis + + :param data: 2D data array + :type data: numpy.ndarray + :return: Smoothed data + :rtype: numpy.ndarray(dtype=numpy.float64) + """ + cdef: + double[::1] data_c + + if not isinstance(data, numpy.ndarray): + if not hasattr(data, "__len__") or not hasattr(data[0], "__len__") or\ + not hasattr(data[0][0], "__len__"): + raise TypeError("data must be a 3D sequence (list, tuple) " + + "or a 3D numpy array") + nx = len(data) + ny = len(data[0]) + nz = len(data[0][0]) + data_shape = (len(data), len(data[0]), len(data[0][0])) + else: + data_shape = data.shape + nrows = data_shape[0] + if len(data_shape) == 3: + nx = data_shape[0] + ny = data_shape[1] + nz = data_shape[2] + else: + raise TypeError("data array must be 3-dimensional") + + data_c = numpy.array(data, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + + filters_wrapper.smooth3d(&data_c[0], nx, ny, nz) + + return numpy.asarray(data_c).reshape(data_shape) diff --git a/src/silx/math/fit/filters/include/filters.h b/src/silx/math/fit/filters/include/filters.h new file mode 100644 index 0000000..1ee9a95 --- /dev/null +++ b/src/silx/math/fit/filters/include/filters.h @@ -0,0 +1,45 @@ +/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +#ifndef FITFILTERS_H +#define FITFILTERS_H + +/* Background functions */ +void snip1d(double *data, int size, int width); +//void snip1d_multiple(double *data, int n_channels, int snip_width, int n_spectra); +void snip2d(double *data, int nrows, int ncolumns, int width); +void snip3d(double *data, int nx, int ny, int nz, int width); + +int strip(double* input, long len_input, double c, long niter, int deltai, + long* anchors, long len_anchors, double* output); + +/* Smoothing functions */ + +int SavitskyGolay(double* input, long len_input, int npoints, double* output); + +void smooth1d(double *data, int size); +void smooth2d(double *data, int size0, int size1); +void smooth3d(double *data, int size0, int size1, int size2); + + +#endif /* #define FITFILTERS_H */ diff --git a/src/silx/math/fit/filters/src/smoothnd.c b/src/silx/math/fit/filters/src/smoothnd.c new file mode 100644 index 0000000..cb96961 --- /dev/null +++ b/src/silx/math/fit/filters/src/smoothnd.c @@ -0,0 +1,317 @@ +#/*########################################################################## +# +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +#include <stdlib.h> +#include <string.h> +#include <math.h> +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +#define MAX_SAVITSKY_GOLAY_WIDTH 101 +#define MIN_SAVITSKY_GOLAY_WIDTH 3 + +/* Wrapped functions */ +void smooth1d(double *data, int size); +void smooth2d(double *data, int size0, int size1); +void smooth3d(double *data, int size0, int size1, int size2); +int SavitskyGolay(double* input, long len_input, int npoints, double* output); + +/* Internal functions */ +long index2d(long row_idx, long col_idx, long ncols); +long index3d(long x_idx, long y_idx, long z_idx, long ny, long nz); +void smooth1d_rows(double *data, long nrows, long ncols); +void smooth1d_cols(double *data, long nrows, long ncols); +void smooth1d_x(double *data, long nx, long ny, long nz); +void smooth1d_y(double *data, long nx, long ny, long nz); +void smooth1d_z(double *data, long nx, long ny, long nz); +void smooth2d_yzslice(double *data, long nx, long ny, long nz); +void smooth2d_xzslice(double *data, long nx, long ny, long nz); +void smooth2d_xyslice(double *data, long nx, long ny, long nz); + + +/* Simple smoothing of a 1D array */ +void smooth1d(double *data, int size) +{ + long i; + double prev_sample; + double next_sample; + + if (size < 3) + { + return; + } + prev_sample = data[0]; + for (i=0; i<(size-1); i++) + { + next_sample = 0.25 * (prev_sample + 2 * data[i] + data[i+1]); + prev_sample = data[i]; + data[i] = next_sample; + } + data[size-1] = 0.25 * prev_sample + 0.75 * data[size-1]; + return; +} + +/* Smoothing of a 2D array*/ +void smooth2d(double *data, int nrows, int ncols) +{ + /* smooth the first dimension (rows) */ + smooth1d_rows(data, nrows, ncols); + + /* smooth the 2nd dimension */ + smooth1d_cols(data, nrows, ncols); +} + +/* Smoothing of a 3D array */ +void smooth3d(double *data, int nx, int ny, int nz) +{ + smooth2d_xyslice(data, nx, ny, nz); + smooth2d_xzslice(data, nx, ny, nz); + smooth2d_yzslice(data, nx, ny, nz); +} + +/* 1D Savitsky-Golay smoothing */ +int SavitskyGolay(double* input, long len_input, int npoints, double* output) +{ + + //double dpoints = 5.; + double coeff[MAX_SAVITSKY_GOLAY_WIDTH]; + int i, j, m; + double dhelp, den; + double *data; + + memcpy(output, input, len_input * sizeof(double)); + + if (!(npoints % 2)) npoints +=1; + + if((npoints < MIN_SAVITSKY_GOLAY_WIDTH) || (len_input < npoints) || \ + (npoints > MAX_SAVITSKY_GOLAY_WIDTH)) + { + /* do not smooth data */ + return 1; + } + + /* calculate the coefficients */ + m = (int) (npoints/2); + den = (double) ((2*m-1) * (2*m+1) * (2*m + 3)); + for (i=0; i<= m; i++){ + coeff[m+i] = (double) (3 * (3*m*m + 3*m - 1 - 5*i*i )); + coeff[m-i] = coeff[m+i]; + } + + /* simple smoothing at the beginning */ + for (j=0; j<=(int)(npoints/3); j++) + { + smooth1d(output, m); + } + + /* simple smoothing at the end */ + for (j=0; j<=(int)(npoints/3); j++) + { + smooth1d((output+len_input-m-1), m); + } + + /*one does not need the whole spectrum buffer, but code is clearer */ + data = (double *) malloc(len_input * sizeof(double)); + memcpy(data, output, len_input * sizeof(double)); + + /* the actual SG smoothing in the middle */ + for (i=m; i<(len_input-m); i++){ + dhelp = 0; + for (j=-m;j<=m;j++) { + dhelp += coeff[m+j] * (*(data+i+j)); + } + if(dhelp > 0.0){ + *(output+i) = dhelp / den; + } + } + free(data); + return (0); +} + +/*********************/ +/* Utility functions */ +/*********************/ + +long index2d(long row_idx, long col_idx, long ncols) +{ + return (row_idx*ncols+col_idx); +} + +/* Apply smooth 1d on all rows in a 2D array*/ +void smooth1d_rows(double *data, long nrows, long ncols) +{ + long row_idx; + + for (row_idx=0; row_idx < nrows; row_idx++) + { + smooth1d(&data[row_idx * ncols], ncols); + } +} + +/* Apply smooth 1d on all columns in a 2D array*/ +void smooth1d_cols(double *data, long nrows, long ncols) +{ + long row_idx, col_idx; + long this_idx2d, next_idx2d; + double prev_sample; + double next_sample; + + for (col_idx=0; col_idx < ncols; col_idx++) + { + prev_sample = data[index2d(0, col_idx, ncols)]; + for (row_idx=0; row_idx<(nrows-1); row_idx++) + { + this_idx2d = index2d(row_idx, col_idx, ncols); + next_idx2d = index2d(row_idx+1, col_idx, ncols); + + next_sample = 0.25 * (prev_sample + \ + 2 * data[this_idx2d] + \ + data[next_idx2d]); + prev_sample = data[this_idx2d]; + data[this_idx2d] = next_sample; + } + + this_idx2d = index2d(nrows-1, col_idx, ncols); + data[this_idx2d] = 0.25 * prev_sample + 0.75 * data[this_idx2d]; + } +} + +long index3d(long x_idx, long y_idx, long z_idx, long ny, long nz) +{ + return ((x_idx*ny + y_idx) * nz + z_idx); +} + +/* Apply smooth 1d along first dimension in a 3D array*/ +void smooth1d_x(double *data, long nx, long ny, long nz) +{ + long x_idx, y_idx, z_idx; + long this_idx3d, next_idx3d; + double prev_sample; + double next_sample; + + for (y_idx=0; y_idx < ny; y_idx++) + { + for (z_idx=0; z_idx < nz; z_idx++) + { + prev_sample = data[index3d(0, y_idx, z_idx, ny, nz)]; + for (x_idx=0; x_idx<(nx-1); x_idx++) + { + this_idx3d = index3d(x_idx, y_idx, z_idx, ny, nz); + next_idx3d = index3d(x_idx+1, y_idx, z_idx, ny, nz); + + next_sample = 0.25 * (prev_sample + \ + 2 * data[this_idx3d] + \ + data[next_idx3d]); + prev_sample = data[this_idx3d]; + data[this_idx3d] = next_sample; + } + + this_idx3d = index3d(nx-1, y_idx, z_idx, ny, nz); + data[this_idx3d] = 0.25 * prev_sample + 0.75 * data[this_idx3d]; + } + } +} + +/* Apply smooth 1d along second dimension in a 3D array*/ +void smooth1d_y(double *data, long nx, long ny, long nz) +{ + long x_idx, y_idx, z_idx; + long this_idx3d, next_idx3d; + double prev_sample; + double next_sample; + + for (x_idx=0; x_idx < nx; x_idx++) + { + for (z_idx=0; z_idx < nz; z_idx++) + { + prev_sample = data[index3d(x_idx, 0, z_idx, ny, nz)]; + for (y_idx=0; y_idx<(ny-1); y_idx++) + { + this_idx3d = index3d(x_idx, y_idx, z_idx, ny, nz); + next_idx3d = index3d(x_idx, y_idx+1, z_idx, ny, nz); + + next_sample = 0.25 * (prev_sample + \ + 2 * data[this_idx3d] + \ + data[next_idx3d]); + prev_sample = data[this_idx3d]; + data[this_idx3d] = next_sample; + } + + this_idx3d = index3d(x_idx, ny-1, z_idx, ny, nz); + data[this_idx3d] = 0.25 * prev_sample + 0.75 * data[this_idx3d]; + } + } +} + +/* Apply smooth 1d along third dimension in a 3D array*/ +void smooth1d_z(double *data, long nx, long ny, long nz) +{ + long x_idx, y_idx; + long idx3d_first_sample; + + for (x_idx=0; x_idx < nx; x_idx++) + { + for (y_idx=0; y_idx < ny; y_idx++) + { + idx3d_first_sample = index3d(x_idx, y_idx, 0, ny, nz); + /*We can use regular 1D smoothing function because z samples + are contiguous in memory*/ + smooth1d(&data[idx3d_first_sample], nz); + } + } +} + +/* 2D smoothing of a YZ slice in a 3D volume*/ +void smooth2d_yzslice(double *data, long nx, long ny, long nz) +{ + long x_idx; + long slice_size = ny * nz; + + /* a YZ slice is a "normal" 2D array of memory-contiguous data*/ + for (x_idx=0; x_idx < nx; x_idx++) + { + smooth2d(&data[x_idx*slice_size], ny, nz); + } +} + +/* 2D smoothing of a XZ slice in a 3D volume*/ +void smooth2d_xzslice(double *data, long nx, long ny, long nz) +{ + + /* smooth along the first dimension */ + smooth1d_x(data, nx, ny, nz); + + /* smooth along the third dimension */ + smooth1d_z(data, nx, ny, nz); +} + +/* 2D smoothing of a XY slice in a 3D volume*/ +void smooth2d_xyslice(double *data, long nx, long ny, long nz) +{ + /* smooth along the first dimension */ + smooth1d_x(data, nx, ny, nz); + + /* smooth along the second dimension */ + smooth1d_y(data, nx, ny, nz); +} + diff --git a/src/silx/math/fit/filters/src/snip1d.c b/src/silx/math/fit/filters/src/snip1d.c new file mode 100644 index 0000000..994a272 --- /dev/null +++ b/src/silx/math/fit/filters/src/snip1d.c @@ -0,0 +1,149 @@ +#/*########################################################################## +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +/* + Implementation of the algorithm SNIP in 1D described in + Miroslav Morhac et al. Nucl. Instruments and Methods in Physics Research A401 (1997) 113-132. + + The original idea for 1D and the low-statistics-digital-filter (lsdf) come from + C.G. Ryan et al. Nucl. Instruments and Methods in Physics Research B34 (1988) 396-402. +*/ +#include <stdlib.h> +#include <string.h> +#include <math.h> + +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +void lls(double *data, int size); +void lls_inv(double *data, int size); +void snip1d(double *data, int n_channels, int snip_width); +void snip1d_multiple(double *data, int n_channels, int snip_width, int n_spectra); +void lsdf(double *data, int size, int fwhm, double f, double A, double M, double ratio); + +void lls(double *data, int size) +{ + int i; + for (i=0; i< size; i++) + { + data[i] = log(log(sqrt(data[i]+1.0)+1.0)+1.0); + } +} + +void lls_inv(double *data, int size) +{ + int i; + double tmp; + for (i=0; i< size; i++) + { + /* slightly different than the published formula because + with the original formula: + + tmp = exp(exp(data[i]-1.0)-1.0); + data[i] = tmp * tmp - 1.0; + + one does not recover the original data */ + + tmp = exp(exp(data[i])-1.0)-1.0; + data[i] = tmp * tmp - 1.0; + } +} + +void lsdf(double *data, int size, int fwhm, double f, double A, double M, double ratio) +{ + int channel, i, j; + double L, R, S; + int width; + double dhelp; + + width = (int) (f * fwhm); + for (channel=width; channel<(size-width); channel++) + { + i = width; + while(i>0) + { + L=0; + R=0; + for(j=channel-i; j<channel; j++) + { + L += data[j]; + } + for(j=channel+1; j<channel+i; j++) + { + R += data[j]; + } + S = data[channel] + L + R; + if (S<M) + { + data[channel] = S /(2*i+1); + break; + } + dhelp = (R+1)/(L+1); + if ((dhelp < ratio) && (dhelp > (1/ratio))) + { + if (S<(A*sqrt(data[channel]))) + { + data[channel] = S /(2*i+1); + break; + } + } + i=i-1; + } + } +} + + +void snip1d(double *data, int n_channels, int snip_width) +{ + snip1d_multiple(data, n_channels, snip_width, 1); +} + +void snip1d_multiple(double *data, int n_channels, int snip_width, int n_spectra) +{ + int i; + int j; + int p; + int offset; + double *w; + + i = (int) (0.5 * snip_width); + /* lsdf(data, size, i, 1.5, 75., 10., 1.3); */ + + w = (double *) malloc(n_channels * sizeof(double)); + + for (j=0; j < n_spectra; j++) + { + offset = j * n_channels; + for (p = snip_width; p > 0; p--) + { + for (i=p; i<(n_channels - p); i++) + { + w[i] = MIN(data[i + offset], 0.5*(data[i + offset - p] + data[ i + offset + p])); + } + for (i=p; i<(n_channels - p); i++) + { + data[i+offset] = w[i]; + } + } + } + free(w); +} diff --git a/src/silx/math/fit/filters/src/snip2d.c b/src/silx/math/fit/filters/src/snip2d.c new file mode 100644 index 0000000..235759c --- /dev/null +++ b/src/silx/math/fit/filters/src/snip2d.c @@ -0,0 +1,96 @@ +#/*########################################################################## +# +# The PyMca X-Ray Fluorescence Toolkit +# +# Copyright (c) 2004-2014 European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +/* + Implementation of the algorithm SNIP in 2D described in + Miroslav Morhac et al. Nucl. Instruments and Methods in Physics Research A401 (1997) 113-132. +*/ +#include <stdlib.h> +#include <string.h> +#include <math.h> +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +void lls(double *data, int size); +void lls_inv(double *data, int size); + +void snip2d(double *data, int nrows, int ncolumns, int width) +{ + int i, j; + int p; + int size; + double *w; + double P1, P2, P3, P4; + double S1, S2, S3, S4; + double dhelp; + int iminuspxncolumns; /* (i-p) * ncolumns */ + int ixncolumns; /* i * ncolumns */ + int ipluspxncolumns; /* (i+p) * ncolumns */ + + size = nrows * ncolumns; + w = (double *) malloc(size * sizeof(double)); + + for (p=width; p > 0; p--) + { + for (i=p; i<(nrows-p); i++) + { + iminuspxncolumns = (i-p) * ncolumns; + ixncolumns = i * ncolumns; + ipluspxncolumns = (i+p) * ncolumns; + for (j=p; j<(ncolumns-p); j++) + { + P4 = data[ iminuspxncolumns + (j-p)]; /* P4 = data[i-p][j-p] */ + S4 = data[ iminuspxncolumns + j]; /* S4 = data[i-p][j] */ + P2 = data[ iminuspxncolumns + (j+p)]; /* P2 = data[i-p][j+p] */ + S3 = data[ ixncolumns + (j-p)]; /* S3 = data[i][j-p] */ + S2 = data[ ixncolumns + (j+p)]; /* S2 = data[i][j+p] */ + P3 = data[ ipluspxncolumns + (j-p)]; /* P3 = data[i+p][j-p] */ + S1 = data[ ipluspxncolumns + j]; /* S1 = data[i+p][j] */ + P1 = data[ ipluspxncolumns + (j+p)]; /* P1 = data[i+p][j+p] */ + dhelp = 0.5*(P1+P3); + S1 = MAX(S1, dhelp) - dhelp; + dhelp = 0.5*(P1+P2); + S2 = MAX(S2, dhelp) - dhelp; + dhelp = 0.5*(P3+P4); + S3 = MAX(S3, dhelp) - dhelp; + dhelp = 0.5*(P2+P4); + S4 = MAX(S4, dhelp) - dhelp; + w[ixncolumns + j] = MIN(data[ixncolumns + j], 0.5 * (S1+S2+S3+S4) + 0.25 * (P1+P2+P3+P4)); + } + } + for (i=p; i<(nrows-p); i++) + { + ixncolumns = i * ncolumns; + for (j=p; j<(ncolumns-p); j++) + { + data[ixncolumns + j] = w[ixncolumns + j]; + } + } + } + free(w); +} diff --git a/src/silx/math/fit/filters/src/snip3d.c b/src/silx/math/fit/filters/src/snip3d.c new file mode 100644 index 0000000..cf48ee4 --- /dev/null +++ b/src/silx/math/fit/filters/src/snip3d.c @@ -0,0 +1,186 @@ +#/*########################################################################## +# +# The PyMca X-Ray Fluorescence Toolkit +# +# Copyright (c) 2004-2014 European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +/* + Implementation of the algorithm SNIP in 3D described in + Miroslav Morhac et al. Nucl. Instruments and Methods in Physics Research A401 (1997) 113-132. +*/ + +#include <stdlib.h> +#include <string.h> +#include <math.h> +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +void lls(double *data, int size); +void lls_inv(double *data, int size); + +void snip3d(double *data, int nx, int ny, int nz, int width) +{ + int i, j, k; + int p; + int size; + double *w; + double P1, P2, P3, P4, P5, P6, P7, P8; + double R1, R2, R3, R4, R5, R6; + double S1, S2, S3, S4, S5, S6, S7, S8, S9, S10, S11, S12; + double dhelp; + long ioffset; + long iplus; + long imin; + long joffset; + long jplus; + long jmin; + + size = nx * ny * nz; + w = (double *) malloc(size * sizeof(double)); + + for (p=width; p > 0; p--) + { + for (i=p; i<(nx-p); i++) + { + ioffset = i * ny * nz; + iplus = (i + p) * ny * nz; + imin = (i - p) * ny * nz; + for (j=p; j<(ny-p); j++) + { + joffset = j * nz; + jplus = (j + p) * nz; + jmin = (j - p) * nz; + for (k=p; k<(nz-p); k++) + { + P1 = data[iplus + jplus + k-p]; /* P1 = data[i+p][j+p][k-p] */ + P2 = data[imin + jplus + k-p]; /* P2 = data[i-p][j+p][k-p] */ + P3 = data[iplus + jmin + k-p]; /* P3 = data[i+p][j-p][k-p] */ + P4 = data[imin + jmin + k-p]; /* P4 = data[i-p][j-p][k-p] */ + P5 = data[iplus + jplus + k+p]; /* P5 = data[i+p][j+p][k+p] */ + P6 = data[imin + jplus + k+p]; /* P6 = data[i-p][j+p][k+p] */ + P7 = data[imin + jmin + k+p]; /* P7 = data[i-p][j-p][k+p] */ + P8 = data[iplus + jmin + k+p]; /* P8 = data[i+p][j-p][k+p] */ + + S1 = data[iplus + joffset + k-p]; /* S1 = data[i+p][j][k-p] */ + S2 = data[ioffset + jmin + k-p]; /* S2 = data[i][j+p][k-p] */ + S3 = data[imin + joffset + k-p]; /* S3 = data[i-p][j][k-p] */ + S4 = data[ioffset + jmin + k-p]; /* S4 = data[i][j-p][k-p] */ + S5 = data[imin + joffset + k+p]; /* S5 = data[i-p][j][k+p] */ + S6 = data[ioffset + jplus + k+p]; /* S6 = data[i][j+p][k+p] */ + S7 = data[imin + joffset + k+p]; /* S7 = data[i-p][j][k+p] */ + S8 = data[ioffset + jmin + k+p]; /* S8 = data[i][j-p][k+p] */ + S9 = data[imin + jplus + k]; /* S9 = data[i-p][j+p][k] */ + S10 = data[imin + jmin + k]; /* S10 = data[i-p][j-p][k] */ + S11 = data[iplus + jmin + k]; /* S11 = data[i+p][j-p][k] */ + S12 = data[iplus + jplus + k]; /* S12 = data[i+p][j+p][k] */ + + R1 = data[ioffset + joffset + k-p]; /* R1 = data[i][j][k-p] */ + R2 = data[ioffset + joffset + k+p]; /* R2 = data[i][j][k+p] */ + R3 = data[imin + joffset + k]; /* R3 = data[i-p][j][k] */ + R4 = data[iplus + joffset + k]; /* R4 = data[i+p][j][k] */ + R5 = data[ioffset + jplus + k]; /* R5 = data[i][j+p][k] */ + R6 = data[ioffset + jmin + k]; /* R6 = data[i][j-p][k] */ + + dhelp = 0.5*(P1+P3); + S1 = MAX(S1, dhelp) - dhelp; + + dhelp = 0.5*(P1+P2); + S2 = MAX(S2, dhelp) - dhelp; + + dhelp = 0.5*(P2+P4); + S3 = MAX(S3, dhelp) - dhelp; + + dhelp = 0.5*(P3+P4); + S4 = MAX(S4, dhelp) - dhelp; + + dhelp = 0.5*(P5+P8); /* Different from paper (P5+P7) but according to drawing */ + S5 = MAX(S5, dhelp) - dhelp; + + dhelp = 0.5*(P5+P6); + S6 = MAX(S6, dhelp) - dhelp; + + dhelp = 0.5*(P6+P7); /* Different from paper (P6+P8) but according to drawing */ + S7 = MAX(S7, dhelp) - dhelp; + + dhelp = 0.5*(P7+P8); + S8 = MAX(S8, dhelp) - dhelp; + + dhelp = 0.5*(P2+P6); + S9 = MAX(S9, dhelp) - dhelp; + + dhelp = 0.5*(P4+P7); /* Different from paper (P4+P8) but according to drawing */ + S10 = MAX(S10, dhelp) - dhelp; + + dhelp = 0.5*(P3+P8); /* Different from paper (P1+P5) but according to drawing */ + S11 = MAX(S11, dhelp) - dhelp; + + dhelp = 0.5*(P1+P5); /* Different from paper (P3+P7) but according to drawing */ + S12 = MAX(S12, dhelp) - dhelp; + + /* The published formulae correspond to have: + P7 and P8 interchanged, and S11 and S12 interchanged + with respect to the published drawing */ + + dhelp = 0.5 * (S1+S2+S3+S4) + 0.25 * (P1+P2+P3+P4); + R1 = MAX(R1, dhelp) - dhelp; + + dhelp = 0.5 * (S5+S6+S7+S8) + 0.25 * (P5+P6+P7+P8); + R2 = MAX(R2, dhelp) - dhelp; + + dhelp = 0.5 * (S3+S7+S9+S10) + 0.25 * (P2+P4+P6+P7); /* Again same P7 and P8 change */ + R3 = MAX(R3, dhelp) - dhelp; + + dhelp = 0.5 * (S1+S5+S11+S12) + 0.25 * (P1+P3+P5+P8); /* Again same P7 and P8 change */ + R4 = MAX(R4, dhelp) - dhelp; + + dhelp = 0.5 * (S2+S6+S9+S12) + 0.25 * (P1+P2+P5+P6); /* Again same S11 and S12 change */ + R5 = MAX(R5, dhelp) - dhelp; + + dhelp = 0.5 * (S4+S8+S10+S11) + 0.25 * (P3+P4+P7+P8); /* Again same S11 and S12 change */ + R6 = MAX(R6, dhelp) - dhelp; + + dhelp = 0.5 * (R1 + R2 + R3 + R4 + R5 + R6) +\ + 0.25 * (S1 + S2 + S3 + S4 + S5 + S6) +\ + 0.25 * (S7 + S8 + S9 + S10 + S11 + S12) +\ + 0.125 * (P1 + P2 + P3 + P4 + P5 + P6 + P7 + P8); + w[ioffset + joffset + k] = MIN(data[ioffset + joffset + k], dhelp); + } + } + } + for (i=p; i<(nx-p); i++) + { + ioffset = i * ny * nz; + for (j=p; j<(ny-p); j++) + { + joffset = j * nz; + for (k=p; k<(nz-p); j++) + { + data[ioffset + joffset + k] = w[ioffset + joffset + k]; + } + } + } + } + free(w); +} diff --git a/src/silx/math/fit/filters/src/strip.c b/src/silx/math/fit/filters/src/strip.c new file mode 100644 index 0000000..dec0742 --- /dev/null +++ b/src/silx/math/fit/filters/src/strip.c @@ -0,0 +1,118 @@ +#/*########################################################################## +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +/* + This file provides a background strip function, to isolate low frequency + background signal from a spectrum (and later substact it from the signal + to be left only with the peaks to be fitted). + + It is adapted from PyMca source file "SpecFitFuns.c". The main difference + with the original code is that this code does not handle the python + wrapping, which is done elsewhere using cython. + + Authors: V.A. Sole, P. Knobel + License: MIT + Last modified: 17/06/2016 +*/ + +#include <string.h> + +#include <stdio.h> + +/* strip(double* input, double c, long niter, double* output) + + The strip background is probably PyMca's most popular background model. + + In its simplest implementation it is just as an iterative procedure depending + on two parameters. These parameters are the strip background width w, and the + strip background number of iterations. At each iteration, if the contents of + channel i, y(i), is above the average of the contents of the channels at w + channels of distance, y(i-w) and y(i+w), y(i) is replaced by the average. + At the end of the process we are left with something that resembles a spectrum + in which the peaks have been "stripped". + + Parameters: + + - input: Input data array + - c: scaling factor applied to the average of y(i-w) and y(i+w) before + comparing to y(i) + - niter: number of iterations + - deltai: operator width (in number of channels) + - anchors: Array of anchors, indices of points that will not be + modified during the stripping procedure. + - output: output array + +*/ +int strip(double* input, long len_input, + double c, long niter, int deltai, + long* anchors, long len_anchors, + double* output) +{ + long iter_index, array_index, anchor_index, anchor; + int anchor_nearby_flag; + double t_mean; + + memcpy(output, input, len_input * sizeof(double)); + + if (deltai <=0) deltai = 1; + + if (len_input < (2*deltai+1)) return(-1); + + if (len_anchors > 0) { + for (iter_index = 0; iter_index < niter; iter_index++) { + for (array_index = deltai; array_index < len_input - deltai; array_index++) { + /* if index is within +- deltai of an anchor, don't do anything */ + anchor_nearby_flag = 0; + for (anchor_index=0; anchor_index<len_anchors; anchor_index++) + { + anchor = anchors[anchor_index]; + if (array_index > (anchor - deltai) && array_index < (anchor + deltai)) + { + anchor_nearby_flag = 1; + break; + } + } + /* skip this array_index index */ + if (anchor_nearby_flag) { + continue; + } + + t_mean = 0.5 * (input[array_index-deltai] + input[array_index+deltai]); + if (input[array_index] > (t_mean * c)) + output[array_index] = t_mean; + } + memcpy(input, output, len_input * sizeof(double)); + } + } + else { + for (iter_index = 0; iter_index < niter; iter_index++) { + for (array_index=deltai; array_index < len_input - deltai; array_index++) { + t_mean = 0.5 * (input[array_index-deltai] + input[array_index+deltai]); + + if (input[array_index] > (t_mean * c)) + output[array_index] = t_mean; + } + memcpy(input, output, len_input * sizeof(double)); + } + } + return(0); +} diff --git a/src/silx/math/fit/filters_wrapper.pxd b/src/silx/math/fit/filters_wrapper.pxd new file mode 100644 index 0000000..e4f7c72 --- /dev/null +++ b/src/silx/math/fit/filters_wrapper.pxd @@ -0,0 +1,71 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "22/06/2016" + +cimport cython + +cdef extern from "filters.h": + void snip1d(double *data, + int size, + int width) + + void snip2d(double *data, + int nrows, + int ncolumns, + int width) + + void snip3d(double *data, + int nx, + int ny, + int nz, + int width) + + int strip(double* input, + long len_input, + double c, + long niter, + int deltai, + long* anchors, + long len_anchors, + double* output) + + int SavitskyGolay(double* input, + long len_input, + int npoints, + double* output) + + void smooth1d(double *data, + int size) + + void smooth2d(double *data, + int size0, + int size1) + + void smooth3d(double *data, + int size0, + int size1, + int size2) diff --git a/src/silx/math/fit/fitmanager.py b/src/silx/math/fit/fitmanager.py new file mode 100644 index 0000000..226e047 --- /dev/null +++ b/src/silx/math/fit/fitmanager.py @@ -0,0 +1,1087 @@ +# coding: utf-8 +# /*######################################################################### +# +# Copyright (c) 2004-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ##########################################################################*/ +""" +This module provides a tool to perform advanced fitting. The actual fit relies +on :func:`silx.math.fit.leastsq`. + +This module deals with: + + - handling of the model functions (using a set of default functions or + loading custom user functions) + - handling of estimation function, that are used to determine the number + of parameters to be fitted for functions with unknown number of + parameters (such as the sum of a variable number of gaussian curves), + and find reasonable initial parameters for input to the iterative + fitting algorithm + - handling of custom derivative functions that can be passed as a + parameter to :func:`silx.math.fit.leastsq` + - providing different background models + +""" +from collections import OrderedDict +import logging +import numpy +from numpy.linalg.linalg import LinAlgError +import os +import sys + +from .filters import strip, smooth1d +from .leastsq import leastsq +from .fittheory import FitTheory +from . import bgtheories + + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "16/01/2017" + +_logger = logging.getLogger(__name__) + + +class FitManager(object): + """ + Fit functions manager + + :param x: Abscissa data. If ``None``, :attr:`xdata` is set to + ``numpy.array([0.0, 1.0, 2.0, ..., len(y)-1])`` + :type x: Sequence or numpy array or None + :param y: The dependant data ``y = f(x)``. ``y`` must have the same + shape as ``x`` if ``x`` is not ``None``. + :type y: Sequence or numpy array or None + :param sigmay: The uncertainties in the ``ydata`` array. These can be + used as weights in the least-squares problem, if ``weight_flag`` + is ``True``. + If ``None``, the uncertainties are assumed to be 1, unless + ``weight_flag`` is ``True``, in which case the square-root + of ``y`` is used. + :type sigmay: Sequence or numpy array or None + :param weight_flag: If this parameter is ``True`` and ``sigmay`` + uncertainties are not specified, the square root of ``y`` is used + as weights in the least-squares problem. If ``False``, the + uncertainties are set to 1. + :type weight_flag: boolean + """ + def __init__(self, x=None, y=None, sigmay=None, weight_flag=False): + """ + """ + self.fitconfig = { + 'WeightFlag': weight_flag, + 'fitbkg': 'No Background', + 'fittheory': None, + # Next few parameters are defined for compatibility with legacy theories + # which take the background as argument for their estimation function + 'StripWidth': 2, + 'StripIterations': 5000, + 'StripThresholdFactor': 1.0, + 'SmoothingFlag': False + } + """Dictionary of fit configuration parameters. + These parameters can be modified using the :meth:`configure` method. + + Keys are: + + - 'fitbkg': name of the function used for fitting a low frequency + background signal + - 'FwhmPoints': default full width at half maximum value for the + peaks'. + - 'Sensitivity': Sensitivity parameter for the peak detection + algorithm (:func:`silx.math.fit.peak_search`) + """ + + self.theories = OrderedDict() + """Dictionary of fit theories, defining functions to be fitted + to individual peaks. + + Keys are descriptive theory names (e.g "Gaussians" or "Step up"). + Values are :class:`silx.math.fit.fittheory.FitTheory` objects with + the following attributes: + + - *"function"* is the fit function for an individual peak + - *"parameters"* is a sequence of parameter names + - *"estimate"* is the parameter estimation function + - *"configure"* is the function returning the configuration dict + for the theory in the format described in the :attr:` fitconfig` + documentation + - *"derivative"* (optional) is a custom derivative function, whose + signature is described in the documentation of + :func:`silx.math.fit.leastsq.leastsq` + (``model_deriv(xdata, parameters, index)``). + - *"description"* is a description string + """ + + self.selectedtheory = None + """Name of currently selected theory. This name matches a key in + :attr:`theories`.""" + + self.bgtheories = OrderedDict() + """Dictionary of background theories. + + See :attr:`theories` for documentation on theories. + """ + + # Load default theories (constant, linear, strip) + self.loadbgtheories(bgtheories) + + self.selectedbg = 'No Background' + """Name of currently selected background theory. This name must be + an existing key in :attr:`bgtheories`.""" + + self.fit_results = [] + """This list stores detailed information about all fit parameters. + It is initialized in :meth:`estimate` and completed with final fit + values in :meth:`runfit`. + + Each fit parameter is stored as a dictionary with following fields: + + - 'name': Parameter name. + - 'estimation': Estimated value. + - 'group': Group number. Group 0 corresponds to the background + function parameters. Group ``n`` (for ``n>0``) corresponds to + the fit function parameters for the n-th peak. + - 'code': Constraint code + + - 0 - FREE + - 1 - POSITIVE + - 2 - QUOTED + - 3 - FIXED + - 4 - FACTOR + - 5 - DELTA + - 6 - SUM + + - 'cons1': + + - Ignored if 'code' is FREE, POSITIVE or FIXED. + - Min value of the parameter if code is QUOTED + - Index of fitted parameter to which 'cons2' is related + if code is FACTOR, DELTA or SUM. + + - 'cons2': + + - Ignored if 'code' is FREE, POSITIVE or FIXED. + - Max value of the parameter if QUOTED + - Factor to apply to related parameter with index 'cons1' if + 'code' is FACTOR + - Difference with parameter with index 'cons1' if + 'code' is DELTA + - Sum obtained when adding parameter with index 'cons1' if + 'code' is SUM + + - 'fitresult': Fitted value. + - 'sigma': Standard deviation for the parameter estimate + - 'xmin': Lower limit of the ``x`` data range on which the fit + was performed + - 'xmax': Upeer limit of the ``x`` data range on which the fit + was performed + """ + + self.parameter_names = [] + """This list stores all fit parameter names: background function + parameters and fit function parameters for every peak. It is filled + in :meth:`estimate`. + + It is the responsibility of the estimate function defined in + :attr:`theories` to determine how many parameters are needed, + based on how many peaks are detected and how many parameters are needed + to fit an individual peak. + """ + + self.setdata(x, y, sigmay) + + ################## + # Public methods # + ################## + def addbackground(self, bgname, bgtheory): + """Add a new background theory to dictionary :attr:`bgtheories`. + + :param bgname: String with the name describing the function + :param bgtheory: :class:`FitTheory` object + :type bgtheory: :class:`silx.math.fit.fittheory.FitTheory` + """ + self.bgtheories[bgname] = bgtheory + + def addtheory(self, name, theory=None, + function=None, parameters=None, + estimate=None, configure=None, derivative=None, + description=None, pymca_legacy=False): + """Add a new theory to dictionary :attr:`theories`. + + You can pass a name and a :class:`FitTheory` object as arguments, or + alternatively provide all arguments necessary to instantiate a new + :class:`FitTheory` object. + + See :meth:`loadtheories` for more information on estimation functions, + configuration functions and custom derivative functions. + + :param name: String with the name describing the function + :param theory: :class:`FitTheory` object, defining a fit function and + associated information (estimation function, description…). + If this parameter is provided, all other parameters, except for + ``name``, are ignored. + :type theory: :class:`silx.math.fit.fittheory.FitTheory` + :param callable function: Mandatory argument if ``theory`` is not provided. + See documentation for :attr:`silx.math.fit.fittheory.FitTheory.function`. + :param List[str] parameters: Mandatory argument if ``theory`` is not provided. + See documentation for :attr:`silx.math.fit.fittheory.FitTheory.parameters`. + :param callable estimate: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.estimate` + :param callable configure: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.configure` + :param callable derivative: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.derivative` + :param str description: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.description` + :param config_widget: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.config_widget` + :param bool pymca_legacy: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.pymca_legacy` + """ + if theory is not None: + self.theories[name] = theory + + elif function is not None and parameters is not None: + self.theories[name] = FitTheory( + description=description, + function=function, + parameters=parameters, + estimate=estimate, + configure=configure, + derivative=derivative, + pymca_legacy=pymca_legacy + ) + + else: + raise TypeError("You must supply a FitTheory object or define " + + "a fit function and its parameters.") + + def addbgtheory(self, name, theory=None, + function=None, parameters=None, + estimate=None, configure=None, + derivative=None, description=None): + """Add a new theory to dictionary :attr:`bgtheories`. + + You can pass a name and a :class:`FitTheory` object as arguments, or + alternatively provide all arguments necessary to instantiate a new + :class:`FitTheory` object. + + :param name: String with the name describing the function + :param theory: :class:`FitTheory` object, defining a fit function and + associated information (estimation function, description…). + If this parameter is provided, all other parameters, except for + ``name``, are ignored. + :type theory: :class:`silx.math.fit.fittheory.FitTheory` + :param function function: Mandatory argument if ``theory`` is not provided. + See documentation for :attr:`silx.math.fit.fittheory.FitTheory.function`. + :param list[str] parameters: Mandatory argument if ``theory`` is not provided. + See documentation for :attr:`silx.math.fit.fittheory.FitTheory.parameters`. + :param function estimate: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.estimate` + :param function configure: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.configure` + :param function derivative: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.derivative` + :param str description: See documentation for + :attr:`silx.math.fit.fittheory.FitTheory.description` + """ + if theory is not None: + self.bgtheories[name] = theory + + elif function is not None and parameters is not None: + self.bgtheories[name] = FitTheory( + description=description, + function=function, + parameters=parameters, + estimate=estimate, + configure=configure, + derivative=derivative, + is_background=True + ) + + else: + raise TypeError("You must supply a FitTheory object or define " + + "a background function and its parameters.") + + def configure(self, **kw): + """Configure the current theory by filling or updating the + :attr:`fitconfig` dictionary. + Call the custom configuration function, if any. This allows the user + to modify the behavior of the custom fit function or the custom + estimate function. + + This methods accepts only named parameters. All ``**kw`` parameters + are expected to be fields of :attr:`fitconfig` to be updated, unless + they have a special meaning for the custom configuration function + of the currently selected theory.. + + This method returns the modified config dictionary returned by the + custom configuration function. + """ + # inspect **kw to find known keys, update them in self.fitconfig + for key in self.fitconfig: + if key in kw: + self.fitconfig[key] = kw[key] + + # initialize dict with existing config dict + result = {} + result.update(self.fitconfig) + + if "WeightFlag" in kw: + if kw["WeightFlag"]: + self.enableweight() + else: + self.disableweight() + + if self.selectedtheory is None: + return result + + # Apply custom configuration function + custom_config_fun = self.theories[self.selectedtheory].configure + if custom_config_fun is not None: + result.update(custom_config_fun(**kw)) + + custom_bg_config_fun = self.bgtheories[self.selectedbg].configure + if custom_bg_config_fun is not None: + result.update(custom_bg_config_fun(**kw)) + + # Update self.fitconfig with custom config + for key in self.fitconfig: + if key in result: + self.fitconfig[key] = result[key] + + result.update(self.fitconfig) + return result + + def estimate(self, callback=None): + """ + Fill :attr:`fit_results` with an estimation of the fit parameters. + + At first, the background parameters are estimated, if a background + model has been specified. + Then, a custom estimation function related to the model function is + called. + + This process determines the number of needed fit parameters and + provides an initial estimation for them, to serve as an input for the + actual iterative fitting performed in :meth:`runfit`. + + :param callback: Optional callback function, conforming to the + signature ``callback(data)`` with ``data`` being a dictionary. + This callback function is called before and after the estimation + process, and is given a dictionary containing the values of + :attr:`state` (``'Estimate in progress'`` or ``'Ready to Fit'``) + and :attr:`chisq`. + This is used for instance in :mod:`silx.gui.fit.FitWidget` to + update a widget displaying a status message. + :return: Estimated parameters + """ + self.state = 'Estimate in progress' + self.chisq = None + + if callback is not None: + callback(data={'chisq': self.chisq, + 'status': self.state}) + + CONS = {0: 'FREE', + 1: 'POSITIVE', + 2: 'QUOTED', + 3: 'FIXED', + 4: 'FACTOR', + 5: 'DELTA', + 6: 'SUM', + 7: 'IGNORE'} + + # Filter-out not finite data + xwork = self.xdata[self._finite_mask] + ywork = self.ydata[self._finite_mask] + + # estimate the background + bg_params, bg_constraints = self.estimate_bkg(xwork, ywork) + + # estimate the function + try: + fun_params, fun_constraints = self.estimate_fun(xwork, ywork) + except LinAlgError: + self.state = 'Estimate failed' + if callback is not None: + callback(data={'status': self.state}) + raise + + # build the names + self.parameter_names = [] + + for bg_param_name in self.bgtheories[self.selectedbg].parameters: + self.parameter_names.append(bg_param_name) + + fun_param_names = self.theories[self.selectedtheory].parameters + param_index, peak_index = 0, 0 + while param_index < len(fun_params): + peak_index += 1 + for fun_param_name in fun_param_names: + self.parameter_names.append(fun_param_name + "%d" % peak_index) + param_index += 1 + + self.fit_results = [] + nb_fun_params_per_group = len(fun_param_names) + group_number = 0 + xmin = min(xwork) + xmax = max(xwork) + nb_bg_params = len(bg_params) + for (pindex, pname) in enumerate(self.parameter_names): + # First come background parameters + if pindex < nb_bg_params: + estimation_value = bg_params[pindex] + constraint_code = CONS[int(bg_constraints[pindex][0])] + cons1 = bg_constraints[pindex][1] + cons2 = bg_constraints[pindex][2] + # then come peak function parameters + else: + fun_param_index = pindex - nb_bg_params + + # increment group_number for each new fitted peak + if (fun_param_index % nb_fun_params_per_group) == 0: + group_number += 1 + + estimation_value = fun_params[fun_param_index] + constraint_code = CONS[int(fun_constraints[fun_param_index][0])] + # cons1 is the index of another fit parameter. In the global + # fit_results, we must adjust the index to account for the bg + # params added to the start of the list. + cons1 = fun_constraints[fun_param_index][1] + if constraint_code in ["FACTOR", "DELTA", "SUM"]: + cons1 += nb_bg_params + cons2 = fun_constraints[fun_param_index][2] + + self.fit_results.append({'name': pname, + 'estimation': estimation_value, + 'group': group_number, + 'code': constraint_code, + 'cons1': cons1, + 'cons2': cons2, + 'fitresult': 0.0, + 'sigma': 0.0, + 'xmin': xmin, + 'xmax': xmax}) + + self.state = 'Ready to Fit' + self.chisq = None + self.niter = 0 + + if callback is not None: + callback(data={'chisq': self.chisq, + 'status': self.state}) + return numpy.append(bg_params, fun_params) + + def fit(self): + """Convenience method to call :meth:`estimate` followed by :meth:`runfit`. + + :return: Output of :meth:`runfit`""" + self.estimate() + return self.runfit() + + def gendata(self, x=None, paramlist=None, estimated=False): + """Return a data array using the currently selected fit function + and the fitted parameters. + + :param x: Independent variable where the function is calculated. + If ``None``, use :attr:`xdata`. + :param paramlist: List of dictionaries, each dictionary item being a + fit parameter. The dictionary's format is documented in + :attr:`fit_results`. + If ``None`` (default), use parameters from :attr:`fit_results`. + :param estimated: If *True*, use estimated parameters. + :return: :meth:`fitfunction` calculated for parameters whose code is + not set to ``"IGNORE"``. + + This calculates :meth:`fitfunction` on `x` data using fit parameters + from a list of parameter dictionaries, if field ``code`` is not set + to ``"IGNORE"``. + """ + x = self.xdata if x is None else numpy.array(x, copy=False) + + if paramlist is None: + paramlist = self.fit_results + active_params = [] + for param in paramlist: + if param['code'] not in ['IGNORE', 7]: + if not estimated: + active_params.append(param['fitresult']) + else: + active_params.append(param['estimation']) + + # Mask x with not finite (support nD x) + finite_mask = numpy.all(numpy.isfinite(x), axis=tuple(range(1, x.ndim))) + + if numpy.all(finite_mask): # All values are finite: fast path + return self.fitfunction(numpy.array(x, copy=True), *active_params) + + else: # Only run fitfunction on finite data and complete result with NaNs + # Create result with same number as elements as x, filling holes with NaNs + result = numpy.full((x.shape[0],), numpy.nan, dtype=numpy.float64) + result[finite_mask] = self.fitfunction( + numpy.array(x[finite_mask], copy=True), *active_params) + return result + + def get_estimation(self): + """Return the list of fit parameter names.""" + if self.state not in ["Ready to fit", "Fit in progress", "Ready"]: + _logger.warning("get_estimation() called before estimate() completed") + return [param["estimation"] for param in self.fit_results] + + def get_names(self): + """Return the list of fit parameter estimations.""" + if self.state not in ["Ready to fit", "Fit in progress", "Ready"]: + msg = "get_names() called before estimate() completed, " + msg += "names are not populated at this stage" + _logger.warning(msg) + return [param["name"] for param in self.fit_results] + + def get_fitted_parameters(self): + """Return the list of fitted parameters.""" + if self.state not in ["Ready"]: + msg = "get_fitted_parameters() called before runfit() completed, " + msg += "results are not available a this stage" + _logger.warning(msg) + return [param["fitresult"] for param in self.fit_results] + + def loadtheories(self, theories): + """Import user defined fit functions defined in an external Python + source file, and save them in :attr:`theories`. + + An example of such a file can be found in the sources of + :mod:`silx.math.fit.fittheories`. It must contain a + dictionary named ``THEORY`` with the following structure:: + + THEORY = { + 'theory_name_1': + FitTheory(description='Description of theory 1', + function=fitfunction1, + parameters=('param name 1', 'param name 2', …), + estimate=estimation_function1, + configure=configuration_function1, + derivative=derivative_function1), + 'theory_name_2': + FitTheory(…), + } + + See documentation of :mod:`silx.math.fit.fittheories` and + :mod:`silx.math.fit.fittheory` for more + information on designing your fit functions file. + + This method can also load user defined functions in the legacy + format used in *PyMca*. + + :param theories: Name of python source file, or module containing the + definition of fit functions. + :raise: ImportError if theories cannot be imported + """ + from types import ModuleType + if isinstance(theories, ModuleType): + theories_module = theories + else: + # if theories is not a module, it must be a string + string_types = (basestring,) if sys.version_info[0] == 2 else (str,) # noqa + if not isinstance(theories, string_types): + raise ImportError("theory must be a python module, a module" + + "name or a python filename") + # if theories is a filename + if os.path.isfile(theories): + sys.path.append(os.path.dirname(theories)) + f = os.path.basename(os.path.splitext(theories)[0]) + theories_module = __import__(f) + # if theories is a module name + else: + theories_module = __import__(theories) + + if hasattr(theories_module, "INIT"): + theories.INIT() + + if not hasattr(theories_module, "THEORY"): + msg = "File %s does not contain a THEORY dictionary" % theories + raise ImportError(msg) + + elif isinstance(theories_module.THEORY, dict): + # silx format for theory definition + for theory_name, fittheory in list(theories_module.THEORY.items()): + self.addtheory(theory_name, fittheory) + else: + self._load_legacy_theories(theories_module) + + def loadbgtheories(self, theories): + """Import user defined background functions defined in an external Python + module (source file), and save them in :attr:`theories`. + + An example of such a file can be found in the sources of + :mod:`silx.math.fit.fittheories`. It must contain a + dictionary named ``THEORY`` with the following structure:: + + THEORY = { + 'theory_name_1': + FitTheory(description='Description of theory 1', + function=fitfunction1, + parameters=('param name 1', 'param name 2', …), + estimate=estimation_function1, + configure=configuration_function1, + 'theory_name_2': + FitTheory(…), + } + + See documentation of :mod:`silx.math.fit.bgtheories` and + :mod:`silx.math.fit.fittheory` for more + information on designing your background functions file. + + :param theories: Module or name of python source file containing the + definition of background functions. + :raise: ImportError if theories cannot be imported + """ + from types import ModuleType + if isinstance(theories, ModuleType): + theories_module = theories + else: + # if theories is not a module, it must be a string + string_types = (basestring,) if sys.version_info[0] == 2 else (str,) # noqa + if not isinstance(theories, string_types): + raise ImportError("theory must be a python module, a module" + + "name or a python filename") + # if theories is a filename + if os.path.isfile(theories): + sys.path.append(os.path.dirname(theories)) + f = os.path.basename(os.path.splitext(theories)[0]) + theories_module = __import__(f) + # if theories is a module name + else: + theories_module = __import__(theories) + + if hasattr(theories_module, "INIT"): + theories.INIT() + + if not hasattr(theories_module, "THEORY"): + msg = "File %s does not contain a THEORY dictionary" % theories + raise ImportError(msg) + + elif isinstance(theories_module.THEORY, dict): + # silx format for theory definition + for theory_name, fittheory in list(theories_module.THEORY.items()): + self.addbgtheory(theory_name, fittheory) + + def setbackground(self, theory): + """Choose a background type from within :attr:`bgtheories`. + + This updates :attr:`selectedbg`. + + :param theory: The name of the background to be used. + :raise: KeyError if ``theory`` is not a key of :attr:`bgtheories``. + """ + if theory in self.bgtheories: + self.selectedbg = theory + else: + msg = "No theory with name %s in bgtheories.\n" % theory + msg += "Available theories: %s\n" % self.bgtheories.keys() + raise KeyError(msg) + + # run configure to apply our fitconfig to the selected theory + # through its custom config function + self.configure(**self.fitconfig) + + def setdata(self, x, y, sigmay=None, xmin=None, xmax=None): + """Set data attributes: + + - ``xdata0``, ``ydata0`` and ``sigmay0`` store the initial data + and uncertainties. These attributes are not modified after + initialization. + - ``xdata``, ``ydata`` and ``sigmay`` store the data after + removing values where ``xdata < xmin`` or ``xdata > xmax``. + These attributes may be modified at a latter stage by filters. + + :param x: Abscissa data. If ``None``, :attr:`xdata`` is set to + ``numpy.array([0.0, 1.0, 2.0, ..., len(y)-1])`` + :type x: Sequence or numpy array or None + :param y: The dependant data ``y = f(x)``. ``y`` must have the same + shape as ``x`` if ``x`` is not ``None``. + :type y: Sequence or numpy array or None + :param sigmay: The uncertainties in the ``ydata`` array. These are + used as weights in the least-squares problem. + If ``None``, the uncertainties are assumed to be 1. + :type sigmay: Sequence or numpy array or None + :param xmin: Lower value of x values to use for fitting + :param xmax: Upper value of x values to use for fitting + """ + if y is None: + self.xdata0 = numpy.array([], numpy.float64) + self.ydata0 = numpy.array([], numpy.float64) + # self.sigmay0 = numpy.array([], numpy.float64) + self.xdata = numpy.array([], numpy.float64) + self.ydata = numpy.array([], numpy.float64) + # self.sigmay = numpy.array([], numpy.float64) + + else: + self.ydata0 = numpy.array(y) + self.ydata = numpy.array(y) + if x is None: + self.xdata0 = numpy.arange(len(self.ydata0)) + self.xdata = numpy.arange(len(self.ydata0)) + else: + self.xdata0 = numpy.array(x) + self.xdata = numpy.array(x) + + # default weight + if sigmay is None: + self.sigmay0 = None + self.sigmay = numpy.sqrt(self.ydata) if self.fitconfig["WeightFlag"] else None + else: + self.sigmay0 = numpy.array(sigmay) + self.sigmay = numpy.array(sigmay) if self.fitconfig["WeightFlag"] else None + + # take the data between limits, using boolean array indexing + if (xmin is not None or xmax is not None) and len(self.xdata): + xmin = xmin if xmin is not None else min(self.xdata) + xmax = xmax if xmax is not None else max(self.xdata) + bool_array = (self.xdata >= xmin) & (self.xdata <= xmax) + self.xdata = self.xdata[bool_array] + self.ydata = self.ydata[bool_array] + self.sigmay = self.sigmay[bool_array] if sigmay is not None else None + + self._finite_mask = numpy.logical_and( + numpy.all(numpy.isfinite(self.xdata), axis=tuple(range(1, self.xdata.ndim))), + numpy.isfinite(self.ydata)) + + def enableweight(self): + """This method can be called to set :attr:`sigmay`. If :attr:`sigmay0` was filled with + actual uncertainties in :meth:`setdata`, use these values. + Else, use ``sqrt(self.ydata)``. + """ + if self.sigmay0 is None: + self.sigmay = numpy.sqrt(self.ydata) if self.fitconfig["WeightFlag"] else None + else: + self.sigmay = self.sigmay0 + + def disableweight(self): + """This method can be called to set :attr:`sigmay` equal to ``None``. + As a result, :func:`leastsq` will consider that the weights in the + least square problem are 1 for all samples.""" + self.sigmay = None + + def settheory(self, theory): + """Pick a theory from :attr:`theories`. + + :param theory: Name of the theory to be used. + :raise: KeyError if ``theory`` is not a key of :attr:`theories`. + """ + if theory is None: + self.selectedtheory = None + elif theory in self.theories: + self.selectedtheory = theory + else: + msg = "No theory with name %s in theories.\n" % theory + msg += "Available theories: %s\n" % self.theories.keys() + raise KeyError(msg) + + # run configure to apply our fitconfig to the selected theory + # through its custom config function + self.configure(**self.fitconfig) + + def runfit(self, callback=None): + """Run the actual fitting and fill :attr:`fit_results` with fit results. + + Before running this method, :attr:`fit_results` must already be + populated with a list of all parameters and their estimated values. + For this, run :meth:`estimate` beforehand. + + :param callback: Optional callback function, conforming to the + signature ``callback(data)`` with ``data`` being a dictionary. + This callback function is called before and after the estimation + process, and is given a dictionary containing the values of + :attr:`state` (``'Fit in progress'`` or ``'Ready'``) + and :attr:`chisq`. + This is used for instance in :mod:`silx.gui.fit.FitWidget` to + update a widget displaying a status message. + :return: Tuple ``(fitted parameters, uncertainties, infodict)``. + *infodict* is the dictionary returned by + :func:`silx.math.fit.leastsq` when called with option + ``full_output=True``. Uncertainties is a sequence of uncertainty + values associated with each fitted parameter. + """ + # self.dataupdate() + + self.state = 'Fit in progress' + self.chisq = None + + if callback is not None: + callback(data={'chisq': self.chisq, + 'status': self.state}) + + param_val = [] + param_constraints = [] + # Initial values are set to the ones computed in estimate() + for param in self.fit_results: + param_val.append(param['estimation']) + param_constraints.append([param['code'], param['cons1'], param['cons2']]) + + # Filter-out not finite data + ywork = self.ydata[self._finite_mask] + xwork = self.xdata[self._finite_mask] + + try: + params, covariance_matrix, infodict = leastsq( + self.fitfunction, # bg + actual model function + xwork, ywork, param_val, + sigma=self.sigmay, + constraints=param_constraints, + model_deriv=self.theories[self.selectedtheory].derivative, + full_output=True, left_derivative=True) + except LinAlgError: + self.state = 'Fit failed' + callback(data={'status': self.state}) + raise + + sigmas = infodict['uncertainties'] + + for i, param in enumerate(self.fit_results): + if param['code'] != 'IGNORE': + param['fitresult'] = params[i] + param['sigma'] = sigmas[i] + + self.chisq = infodict["reduced_chisq"] + self.niter = infodict["niter"] + self.state = 'Ready' + + if callback is not None: + callback(data={'chisq': self.chisq, + 'status': self.state}) + + return params, sigmas, infodict + + ################### + # Private methods # + ################### + def fitfunction(self, x, *pars): + """Function to be fitted. + + This is the sum of the selected background function plus + the selected fit model function. + + :param x: Independent variable where the function is calculated. + :param pars: Sequence of all fit parameters. The first few parameters + are background parameters, then come the peak function parameters. + :return: Output of the fit function with ``x`` as input and ``pars`` + as fit parameters. + """ + result = numpy.zeros(numpy.shape(x), numpy.float64) + + if self.selectedbg is not None: + bg_pars_list = self.bgtheories[self.selectedbg].parameters + nb_bg_pars = len(bg_pars_list) + + bgfun = self.bgtheories[self.selectedbg].function + result += bgfun(x, self.ydata, *pars[0:nb_bg_pars]) + else: + nb_bg_pars = 0 + + selectedfun = self.theories[self.selectedtheory].function + result += selectedfun(x, *pars[nb_bg_pars:]) + + return result + + def estimate_bkg(self, x, y): + """Estimate background parameters using the function defined in + the current fit configuration. + + To change the selected background model, attribute :attr:`selectdbg` + must be changed using method :meth:`setbackground`. + + The actual background function to be used is + referenced in :attr:`bgtheories` + + :param x: Sequence of x data + :param y: sequence of y data + :return: Tuple of two sequences and one data array + ``(estimated_param, constraints, bg_data)``: + + - ``estimated_param`` is a list of estimated values for each + background parameter. + - ``constraints`` is a 2D sequence of dimension ``(n_parameters, 3)`` + + - ``constraints[i][0]``: Constraint code. + See explanation about codes in :attr:`fit_results` + + - ``constraints[i][1]`` + See explanation about 'cons1' in :attr:`fit_results` + documentation. + + - ``constraints[i][2]`` + See explanation about 'cons2' in :attr:`fit_results` + documentation. + """ + background_estimate_function = self.bgtheories[self.selectedbg].estimate + if background_estimate_function is not None: + return background_estimate_function(x, y) + else: + return [], [] + + def estimate_fun(self, x, y): + """Estimate fit parameters using the function defined in + the current fit configuration. + + :param x: Sequence of x data + :param y: sequence of y data + :param bg: Background signal, to be subtracted from ``y`` before fitting. + :return: Tuple of two sequences ``(estimated_param, constraints)``: + + - ``estimated_param`` is a list of estimated values for each + background parameter. + - ``constraints`` is a 2D sequence of dimension (n_parameters, 3) + + - ``constraints[i][0]``: Constraint code. + See explanation about codes in :attr:`fit_results` + + - ``constraints[i][1]`` + See explanation about 'cons1' in :attr:`fit_results` + documentation. + + - ``constraints[i][2]`` + See explanation about 'cons2' in :attr:`fit_results` + documentation. + :raise: ``TypeError`` if estimation function is not callable + + """ + estimatefunction = self.theories[self.selectedtheory].estimate + if hasattr(estimatefunction, '__call__'): + if not self.theories[self.selectedtheory].pymca_legacy: + return estimatefunction(x, y) + else: + # legacy pymca estimate functions have a different signature + if self.fitconfig["fitbkg"] == "No Background": + bg = numpy.zeros_like(y) + else: + if self.fitconfig["SmoothingFlag"]: + y = smooth1d(y) + bg = strip(y, + w=self.fitconfig["StripWidth"], + niterations=self.fitconfig["StripIterations"], + factor=self.fitconfig["StripThresholdFactor"]) + # fitconfig can be filled by user defined config function + xscaling = self.fitconfig.get('Xscaling', 1.0) + yscaling = self.fitconfig.get('Yscaling', 1.0) + return estimatefunction(x, y, bg, xscaling, yscaling) + else: + raise TypeError("Estimation function in attribute " + + "theories[%s]" % self.selectedtheory + + " must be callable.") + + def _load_legacy_theories(self, theories_module): + """Load theories from a custom module in the old PyMca format. + + See PyMca5.PyMcaMath.fitting.SpecfitFunctions for an example. + """ + mandatory_attributes = ["THEORY", "PARAMETERS", + "FUNCTION", "ESTIMATE"] + err_msg = "Custom fit function file must define: " + err_msg += ", ".join(mandatory_attributes) + for attr in mandatory_attributes: + if not hasattr(theories_module, attr): + raise ImportError(err_msg) + + derivative = theories_module.DERIVATIVE if hasattr(theories_module, "DERIVATIVE") else None + configure = theories_module.CONFIGURE if hasattr(theories_module, "CONFIGURE") else None + estimate = theories_module.ESTIMATE if hasattr(theories_module, "ESTIMATE") else None + if isinstance(theories_module.THEORY, (list, tuple)): + # multiple fit functions + for i in range(len(theories_module.THEORY)): + deriv = derivative[i] if derivative is not None else None + config = configure[i] if configure is not None else None + estim = estimate[i] if estimate is not None else None + self.addtheory(theories_module.THEORY[i], + FitTheory( + theories_module.FUNCTION[i], + theories_module.PARAMETERS[i], + estim, + config, + deriv, + pymca_legacy=True)) + else: + # single fit function + self.addtheory(theories_module.THEORY, + FitTheory( + theories_module.FUNCTION, + theories_module.PARAMETERS, + estimate, + configure, + derivative, + pymca_legacy=True)) + + +def test(): + from .functions import sum_gauss + from . import fittheories + from . import bgtheories + + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(1000).astype(numpy.float64) + + p = [1000, 100., 250, + 255, 690., 45, + 1500, 800.5, 95] + y = 0.5 * x + 13 + sum_gauss(x, *p) + + # Fitting + fit = FitManager() + # more sensitivity necessary to resolve + # overlapping peaks at x=690 and x=800.5 + fit.setdata(x=x, y=y) + fit.loadtheories(fittheories) + fit.settheory('Gaussians') + fit.loadbgtheories(bgtheories) + fit.setbackground('Linear') + fit.estimate() + fit.runfit() + + print("Searched parameters = ", p) + print("Obtained parameters : ") + dummy_list = [] + for param in fit.fit_results: + print(param['name'], ' = ', param['fitresult']) + dummy_list.append(param['fitresult']) + print("chisq = ", fit.chisq) + + # Plot + constant, slope = dummy_list[:2] + p1 = dummy_list[2:] + print(p1) + y2 = slope * x + constant + sum_gauss(x, *p1) + + try: + from silx.gui import qt + from silx.gui.plot.PlotWindow import PlotWindow + app = qt.QApplication([]) + pw = PlotWindow(control=True) + pw.addCurve(x, y, "Original") + pw.addCurve(x, y2, "Fit result") + pw.legendsDockWidget.show() + pw.show() + app.exec() + except ImportError: + _logger.warning("Could not import qt to display fit result as curve") + + +if __name__ == "__main__": + test() diff --git a/src/silx/math/fit/fittheories.py b/src/silx/math/fit/fittheories.py new file mode 100644 index 0000000..5461416 --- /dev/null +++ b/src/silx/math/fit/fittheories.py @@ -0,0 +1,1374 @@ +# coding: utf-8 +#/*########################################################################## +# +# Copyright (c) 2004-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +########################################################################### */ +"""This modules provides a set of fit functions and associated +estimation functions in a format that can be imported into a +:class:`silx.math.fit.FitManager` instance. + +These functions are well suited for fitting multiple gaussian shaped peaks +typically found in spectroscopy data. The estimation functions are designed +to detect how many peaks are present in the data, and provide an initial +estimate for their height, their center location and their full-width +at half maximum (fwhm). + +The limitation of these estimation algorithms is that only gaussians having a +similar fwhm can be detected by the peak search algorithm. +This *search fwhm* can be defined by the user, if +he knows the characteristics of his data, or can be automatically estimated +based on the fwhm of the largest peak in the data. + +The source code of this module can serve as template for defining your own +fit functions. + +The functions to be imported by :meth:`FitManager.loadtheories` are defined by +a dictionary :const:`THEORY`: with the following structure:: + + from silx.math.fit.fittheory import FitTheory + + THEORY = { + 'theory_name_1': FitTheory( + description='Description of theory 1', + function=fitfunction1, + parameters=('param name 1', 'param name 2', …), + estimate=estimation_function1, + configure=configuration_function1, + derivative=derivative_function1), + + 'theory_name_2': FitTheory(…), + } + +.. note:: + + Consider using an OrderedDict instead of a regular dictionary, when + defining your own theory dictionary, if the order matters to you. + This will likely be the case if you intend to load a selection of + functions in a GUI such as :class:`silx.gui.fit.FitManager`. + +Theory names can be customized (e.g. ``gauss, lorentz, splitgauss``…). + +The mandatory parameters for :class:`FitTheory` are ``function`` and +``parameters``. + +You can also define an ``INIT`` function that will be executed by +:meth:`FitManager.loadtheories`. + +See the documentation of :class:`silx.math.fit.fittheory.FitTheory` +for more information. + +Module members: +--------------- +""" +import numpy +from collections import OrderedDict +import logging + +from silx.math.fit import functions +from silx.math.fit.peaks import peak_search, guess_fwhm +from silx.math.fit.filters import strip, savitsky_golay +from silx.math.fit.leastsq import leastsq +from silx.math.fit.fittheory import FitTheory + +_logger = logging.getLogger(__name__) + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "15/05/2017" + + +DEFAULT_CONFIG = { + 'NoConstraintsFlag': False, + 'PositiveFwhmFlag': True, + 'PositiveHeightAreaFlag': True, + 'SameFwhmFlag': False, + 'QuotedPositionFlag': False, # peak not outside data range + 'QuotedEtaFlag': False, # force 0 < eta < 1 + # Peak detection + 'AutoScaling': False, + 'Yscaling': 1.0, + 'FwhmPoints': 8, + 'AutoFwhm': True, + 'Sensitivity': 2.5, + 'ForcePeakPresence': True, + # Hypermet + 'HypermetTails': 15, + 'QuotedFwhmFlag': 0, + 'MaxFwhm2InputRatio': 1.5, + 'MinFwhm2InputRatio': 0.4, + # short tail parameters + 'MinGaussArea4ShortTail': 50000., + 'InitialShortTailAreaRatio': 0.050, + 'MaxShortTailAreaRatio': 0.100, + 'MinShortTailAreaRatio': 0.0010, + 'InitialShortTailSlopeRatio': 0.70, + 'MaxShortTailSlopeRatio': 2.00, + 'MinShortTailSlopeRatio': 0.50, + # long tail parameters + 'MinGaussArea4LongTail': 1000.0, + 'InitialLongTailAreaRatio': 0.050, + 'MaxLongTailAreaRatio': 0.300, + 'MinLongTailAreaRatio': 0.010, + 'InitialLongTailSlopeRatio': 20.0, + 'MaxLongTailSlopeRatio': 50.0, + 'MinLongTailSlopeRatio': 5.0, + # step tail + 'MinGaussHeight4StepTail': 5000., + 'InitialStepTailHeightRatio': 0.002, + 'MaxStepTailHeightRatio': 0.0100, + 'MinStepTailHeightRatio': 0.0001, + # Hypermet constraints + # position in range [estimated position +- estimated fwhm/2] + 'HypermetQuotedPositionFlag': True, + 'DeltaPositionFwhmUnits': 0.5, + 'SameSlopeRatioFlag': 1, + 'SameAreaRatioFlag': 1, + # Strip bg removal + 'StripBackgroundFlag': True, + 'SmoothingFlag': True, + 'SmoothingWidth': 5, + 'StripWidth': 2, + 'StripIterations': 5000, + 'StripThresholdFactor': 1.0} +"""This dictionary defines default configuration parameters that have effects +on fit functions and estimation functions, mainly on fit constraints. +This dictionary is accessible as attribute :attr:`FitTheories.config`, +which can be modified by configuration functions defined in +:const:`CONFIGURE`. +""" + +CFREE = 0 +CPOSITIVE = 1 +CQUOTED = 2 +CFIXED = 3 +CFACTOR = 4 +CDELTA = 5 +CSUM = 6 +CIGNORED = 7 + + +class FitTheories(object): + """Class wrapping functions from :class:`silx.math.fit.functions` + and providing estimate functions for all of these fit functions.""" + def __init__(self, config=None): + if config is None: + self.config = DEFAULT_CONFIG + else: + self.config = config + + def ahypermet(self, x, *pars): + """ + Wrapping of :func:`silx.math.fit.functions.sum_ahypermet` without + the tail flags in the function signature. + + Depending on the value of `self.config['HypermetTails']`, one can + activate or deactivate the various terms of the hypermet function. + + `self.config['HypermetTails']` must be an integer between 0 and 15. + It is a set of 4 binary flags, one for activating each one of the + hypermet terms: *gaussian function, short tail, long tail, step*. + + For example, 15 can be expressed as ``1111`` in base 2, so a flag of + 15 means all terms are active. + """ + g_term = self.config['HypermetTails'] & 1 + st_term = (self.config['HypermetTails'] >> 1) & 1 + lt_term = (self.config['HypermetTails'] >> 2) & 1 + step_term = (self.config['HypermetTails'] >> 3) & 1 + return functions.sum_ahypermet(x, *pars, + gaussian_term=g_term, st_term=st_term, + lt_term=lt_term, step_term=step_term) + + def poly(self, x, *pars): + """Order n polynomial. + The order of the polynomial is defined by the number of + coefficients (``*pars``). + + """ + p = numpy.poly1d(pars) + return p(x) + + @staticmethod + def estimate_poly(x, y, n=2): + """Estimate polynomial coefficients for a degree n polynomial. + + """ + pcoeffs = numpy.polyfit(x, y, n) + constraints = numpy.zeros((n + 1, 3), numpy.float64) + return pcoeffs, constraints + + def estimate_quadratic(self, x, y): + """Estimate quadratic coefficients + + """ + return self.estimate_poly(x, y, n=2) + + def estimate_cubic(self, x, y): + """Estimate coefficients for a degree 3 polynomial + + """ + return self.estimate_poly(x, y, n=3) + + def estimate_quartic(self, x, y): + """Estimate coefficients for a degree 4 polynomial + + """ + return self.estimate_poly(x, y, n=4) + + def estimate_quintic(self, x, y): + """Estimate coefficients for a degree 5 polynomial + + """ + return self.estimate_poly(x, y, n=5) + + def strip_bg(self, y): + """Return the strip background of y, using parameters from + :attr:`config` dictionary (*StripBackgroundFlag, StripWidth, + StripIterations, StripThresholdFactor*)""" + remove_strip_bg = self.config.get('StripBackgroundFlag', False) + if remove_strip_bg: + if self.config['SmoothingFlag']: + y = savitsky_golay(y, self.config['SmoothingWidth']) + strip_width = self.config['StripWidth'] + strip_niterations = self.config['StripIterations'] + strip_thr_factor = self.config['StripThresholdFactor'] + return strip(y, w=strip_width, + niterations=strip_niterations, + factor=strip_thr_factor) + else: + return numpy.zeros_like(y) + + def guess_yscaling(self, y): + """Estimate scaling for y prior to peak search. + A smoothing filter is applied to y to estimate the noise level + (chi-squared) + + :param y: Data array + :return: Scaling factor + """ + # ensure y is an array + yy = numpy.array(y, copy=False) + + # smooth + convolution_kernel = numpy.ones(shape=(3,)) / 3. + ysmooth = numpy.convolve(y, convolution_kernel, mode="same") + + # remove zeros + idx_array = numpy.fabs(y) > 0.0 + yy = yy[idx_array] + ysmooth = ysmooth[idx_array] + + # compute scaling factor + chisq = numpy.mean((yy - ysmooth)**2 / numpy.fabs(yy)) + if chisq > 0: + return 1. / chisq + else: + return 1.0 + + def peak_search(self, y, fwhm, sensitivity): + """Search for peaks in y array, after padding the array and + multiplying its value by a scaling factor. + + :param y: 1-D data array + :param int fwhm: Typical full width at half maximum for peaks, + in number of points. This parameter is used for to discriminate between + true peaks and background fluctuations. + :param float sensitivity: Sensitivity parameter. This is a threshold factor + for peak detection. Only peaks larger than the standard deviation + of the noise multiplied by this sensitivity parameter are detected. + :return: List of peak indices + """ + # add padding + ysearch = numpy.ones((len(y) + 2 * fwhm,), numpy.float64) + ysearch[0:fwhm] = y[0] + ysearch[-1:-fwhm - 1:-1] = y[len(y)-1] + ysearch[fwhm:fwhm + len(y)] = y[:] + + scaling = self.guess_yscaling(y) if self.config["AutoScaling"] else self.config["Yscaling"] + + if len(ysearch) > 1.5 * fwhm: + peaks = peak_search(scaling * ysearch, + fwhm=fwhm, sensitivity=sensitivity) + return [peak_index - fwhm for peak_index in peaks + if 0 <= peak_index - fwhm < len(y)] + else: + return [] + + def estimate_height_position_fwhm(self, x, y): + """Estimation of *Height, Position, FWHM* of peaks, for gaussian-like + curves. + + This functions finds how many parameters are needed, based on the + number of peaks detected. Then it estimates the fit parameters + with a few iterations of fitting gaussian functions. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Height, Position, FWHM*. + Fit constraints depend on :attr:`config`. + """ + fittedpar = [] + + bg = self.strip_bg(y) + + if self.config['AutoFwhm']: + search_fwhm = guess_fwhm(y) + else: + search_fwhm = int(float(self.config['FwhmPoints'])) + search_sens = float(self.config['Sensitivity']) + + if search_fwhm < 3: + _logger.warning("Setting peak fwhm to 3 (lower limit)") + search_fwhm = 3 + self.config['FwhmPoints'] = 3 + + if search_sens < 1: + _logger.warning("Setting peak search sensitivity to 1. " + + "(lower limit to filter out noise peaks)") + search_sens = 1 + self.config['Sensitivity'] = 1 + + npoints = len(y) + + # Find indices of peaks in data array + peaks = self.peak_search(y, + fwhm=search_fwhm, + sensitivity=search_sens) + + if not len(peaks): + forcepeak = int(float(self.config.get('ForcePeakPresence', 0))) + if forcepeak: + delta = y - bg + # get index of global maximum + # (first one if several samples are equal to this value) + peaks = [numpy.nonzero(delta == delta.max())[0][0]] + + # Find index of largest peak in peaks array + index_largest_peak = 0 + if len(peaks) > 0: + # estimate fwhm as 5 * sampling interval + sig = 5 * abs(x[npoints - 1] - x[0]) / npoints + peakpos = x[int(peaks[0])] + if abs(peakpos) < 1.0e-16: + peakpos = 0.0 + param = numpy.array( + [y[int(peaks[0])] - bg[int(peaks[0])], peakpos, sig]) + height_largest_peak = param[0] + peak_index = 1 + for i in peaks[1:]: + param2 = numpy.array( + [y[int(i)] - bg[int(i)], x[int(i)], sig]) + param = numpy.concatenate((param, param2)) + if param2[0] > height_largest_peak: + height_largest_peak = param2[0] + index_largest_peak = peak_index + peak_index += 1 + + # Subtract background + xw = x + yw = y - bg + + cons = numpy.zeros((len(param), 3), numpy.float64) + + # peak height must be positive + cons[0:len(param):3, 0] = CPOSITIVE + # force peaks to stay around their position + cons[1:len(param):3, 0] = CQUOTED + + # set possible peak range to estimated peak +- guessed fwhm + if len(xw) > search_fwhm: + fwhmx = numpy.fabs(xw[int(search_fwhm)] - xw[0]) + cons[1:len(param):3, 1] = param[1:len(param):3] - 0.5 * fwhmx + cons[1:len(param):3, 2] = param[1:len(param):3] + 0.5 * fwhmx + else: + shape = [max(1, int(x)) for x in (param[1:len(param):3])] + cons[1:len(param):3, 1] = min(xw) * numpy.ones( + shape, + numpy.float64) + cons[1:len(param):3, 2] = max(xw) * numpy.ones( + shape, + numpy.float64) + + # ensure fwhm is positive + cons[2:len(param):3, 0] = CPOSITIVE + + # run a quick iterative fit (4 iterations) to improve + # estimations + fittedpar, _, _ = leastsq(functions.sum_gauss, xw, yw, param, + max_iter=4, constraints=cons.tolist(), + full_output=True) + + # set final constraints based on config parameters + cons = numpy.zeros((len(fittedpar), 3), numpy.float64) + peak_index = 0 + for i in range(len(peaks)): + # Setup height area constrains + if not self.config['NoConstraintsFlag']: + if self.config['PositiveHeightAreaFlag']: + cons[peak_index, 0] = CPOSITIVE + cons[peak_index, 1] = 0 + cons[peak_index, 2] = 0 + peak_index += 1 + + # Setup position constrains + if not self.config['NoConstraintsFlag']: + if self.config['QuotedPositionFlag']: + cons[peak_index, 0] = CQUOTED + cons[peak_index, 1] = min(x) + cons[peak_index, 2] = max(x) + peak_index += 1 + + # Setup positive FWHM constrains + if not self.config['NoConstraintsFlag']: + if self.config['PositiveFwhmFlag']: + cons[peak_index, 0] = CPOSITIVE + cons[peak_index, 1] = 0 + cons[peak_index, 2] = 0 + if self.config['SameFwhmFlag']: + if i != index_largest_peak: + cons[peak_index, 0] = CFACTOR + cons[peak_index, 1] = 3 * index_largest_peak + 2 + cons[peak_index, 2] = 1.0 + peak_index += 1 + + return fittedpar, cons + + def estimate_agauss(self, x, y): + """Estimation of *Area, Position, FWHM* of peaks, for gaussian-like + curves. + + This functions uses :meth:`estimate_height_position_fwhm`, then + converts the height parameters to area under the curve with the + formula ``area = sqrt(2*pi) * height * fwhm / (2 * sqrt(2 * log(2))`` + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Area, Position, FWHM*. + Fit constraints depend on :attr:`config`. + """ + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + # get the number of found peaks + npeaks = len(fittedpar) // 3 + for i in range(npeaks): + height = fittedpar[3 * i] + fwhm = fittedpar[3 * i + 2] + # Replace height with area in fittedpar + fittedpar[3 * i] = numpy.sqrt(2 * numpy.pi) * height * fwhm / ( + 2.0 * numpy.sqrt(2 * numpy.log(2))) + return fittedpar, cons + + def estimate_alorentz(self, x, y): + """Estimation of *Area, Position, FWHM* of peaks, for Lorentzian + curves. + + This functions uses :meth:`estimate_height_position_fwhm`, then + converts the height parameters to area under the curve with the + formula ``area = height * fwhm * 0.5 * pi`` + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Area, Position, FWHM*. + Fit constraints depend on :attr:`config`. + """ + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + # get the number of found peaks + npeaks = len(fittedpar) // 3 + for i in range(npeaks): + height = fittedpar[3 * i] + fwhm = fittedpar[3 * i + 2] + # Replace height with area in fittedpar + fittedpar[3 * i] = (height * fwhm * 0.5 * numpy.pi) + return fittedpar, cons + + def estimate_splitgauss(self, x, y): + """Estimation of *Height, Position, FWHM1, FWHM2* of peaks, for + asymmetric gaussian-like curves. + + This functions uses :meth:`estimate_height_position_fwhm`, then + adds a second (identical) estimation of FWHM to the fit parameters + for each peak, and the corresponding constraint. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Height, Position, FWHM1, FWHM2*. + Fit constraints depend on :attr:`config`. + """ + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + # get the number of found peaks + npeaks = len(fittedpar) // 3 + estimated_parameters = [] + estimated_constraints = numpy.zeros((4 * npeaks, 3), numpy.float64) + for i in range(npeaks): + for j in range(3): + estimated_parameters.append(fittedpar[3 * i + j]) + # fwhm2 estimate = fwhm1 + estimated_parameters.append(fittedpar[3 * i + 2]) + # height + estimated_constraints[4 * i, 0] = cons[3 * i, 0] + estimated_constraints[4 * i, 1] = cons[3 * i, 1] + estimated_constraints[4 * i, 2] = cons[3 * i, 2] + # position + estimated_constraints[4 * i + 1, 0] = cons[3 * i + 1, 0] + estimated_constraints[4 * i + 1, 1] = cons[3 * i + 1, 1] + estimated_constraints[4 * i + 1, 2] = cons[3 * i + 1, 2] + # fwhm1 + estimated_constraints[4 * i + 2, 0] = cons[3 * i + 2, 0] + estimated_constraints[4 * i + 2, 1] = cons[3 * i + 2, 1] + estimated_constraints[4 * i + 2, 2] = cons[3 * i + 2, 2] + # fwhm2 + estimated_constraints[4 * i + 3, 0] = cons[3 * i + 2, 0] + estimated_constraints[4 * i + 3, 1] = cons[3 * i + 2, 1] + estimated_constraints[4 * i + 3, 2] = cons[3 * i + 2, 2] + if cons[3 * i + 2, 0] == CFACTOR: + # convert indices of related parameters + # (this happens if SameFwhmFlag == True) + estimated_constraints[4 * i + 2, 1] = \ + int(cons[3 * i + 2, 1] / 3) * 4 + 2 + estimated_constraints[4 * i + 3, 1] = \ + int(cons[3 * i + 2, 1] / 3) * 4 + 3 + return estimated_parameters, estimated_constraints + + def estimate_pvoigt(self, x, y): + """Estimation of *Height, Position, FWHM, eta* of peaks, for + pseudo-Voigt curves. + + Pseudo-Voigt are a sum of a gaussian curve *G(x)* and a lorentzian + curve *L(x)* with the same height, center, fwhm parameters: + ``y(x) = eta * G(x) + (1-eta) * L(x)`` + + This functions uses :meth:`estimate_height_position_fwhm`, then + adds a constant estimation of *eta* (0.5) to the fit parameters + for each peak, and the corresponding constraint. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Height, Position, FWHM, eta*. + Constraint for the eta parameter can be set to QUOTED (0.--1.) + by setting :attr:`config`['QuotedEtaFlag'] to ``True``. + If this is not the case, the constraint code is set to FREE. + """ + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + npeaks = len(fittedpar) // 3 + newpar = [] + newcons = numpy.zeros((4 * npeaks, 3), numpy.float64) + # find out related parameters proper index + if not self.config['NoConstraintsFlag']: + if self.config['SameFwhmFlag']: + j = 0 + # get the index of the free FWHM + for i in range(npeaks): + if cons[3 * i + 2, 0] != 4: + j = i + for i in range(npeaks): + if i != j: + cons[3 * i + 2, 1] = 4 * j + 2 + for i in range(npeaks): + newpar.append(fittedpar[3 * i]) + newpar.append(fittedpar[3 * i + 1]) + newpar.append(fittedpar[3 * i + 2]) + newpar.append(0.5) + # height + newcons[4 * i, 0] = cons[3 * i, 0] + newcons[4 * i, 1] = cons[3 * i, 1] + newcons[4 * i, 2] = cons[3 * i, 2] + # position + newcons[4 * i + 1, 0] = cons[3 * i + 1, 0] + newcons[4 * i + 1, 1] = cons[3 * i + 1, 1] + newcons[4 * i + 1, 2] = cons[3 * i + 1, 2] + # fwhm + newcons[4 * i + 2, 0] = cons[3 * i + 2, 0] + newcons[4 * i + 2, 1] = cons[3 * i + 2, 1] + newcons[4 * i + 2, 2] = cons[3 * i + 2, 2] + # Eta constrains + newcons[4 * i + 3, 0] = CFREE + newcons[4 * i + 3, 1] = 0 + newcons[4 * i + 3, 2] = 0 + if self.config['QuotedEtaFlag']: + newcons[4 * i + 3, 0] = CQUOTED + newcons[4 * i + 3, 1] = 0.0 + newcons[4 * i + 3, 2] = 1.0 + return newpar, newcons + + def estimate_splitpvoigt(self, x, y): + """Estimation of *Height, Position, FWHM1, FWHM2, eta* of peaks, for + asymmetric pseudo-Voigt curves. + + This functions uses :meth:`estimate_height_position_fwhm`, then + adds an identical FWHM2 parameter and a constant estimation of + *eta* (0.5) to the fit parameters for each peak, and the corresponding + constraints. + + Constraint for the eta parameter can be set to QUOTED (0.--1.) + by setting :attr:`config`['QuotedEtaFlag'] to ``True``. + If this is not the case, the constraint code is set to FREE. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Height, Position, FWHM1, FWHM2, eta*. + """ + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + npeaks = len(fittedpar) // 3 + newpar = [] + newcons = numpy.zeros((5 * npeaks, 3), numpy.float64) + # find out related parameters proper index + if not self.config['NoConstraintsFlag']: + if self.config['SameFwhmFlag']: + j = 0 + # get the index of the free FWHM + for i in range(npeaks): + if cons[3 * i + 2, 0] != 4: + j = i + for i in range(npeaks): + if i != j: + cons[3 * i + 2, 1] = 4 * j + 2 + for i in range(npeaks): + # height + newpar.append(fittedpar[3 * i]) + # position + newpar.append(fittedpar[3 * i + 1]) + # fwhm1 + newpar.append(fittedpar[3 * i + 2]) + # fwhm2 estimate equal to fwhm1 + newpar.append(fittedpar[3 * i + 2]) + # eta + newpar.append(0.5) + # constraint codes + # ---------------- + # height + newcons[5 * i, 0] = cons[3 * i, 0] + # position + newcons[5 * i + 1, 0] = cons[3 * i + 1, 0] + # fwhm1 + newcons[5 * i + 2, 0] = cons[3 * i + 2, 0] + # fwhm2 + newcons[5 * i + 3, 0] = cons[3 * i + 2, 0] + # cons 1 + # ------ + newcons[5 * i, 1] = cons[3 * i, 1] + newcons[5 * i + 1, 1] = cons[3 * i + 1, 1] + newcons[5 * i + 2, 1] = cons[3 * i + 2, 1] + newcons[5 * i + 3, 1] = cons[3 * i + 2, 1] + # cons 2 + # ------ + newcons[5 * i, 2] = cons[3 * i, 2] + newcons[5 * i + 1, 2] = cons[3 * i + 1, 2] + newcons[5 * i + 2, 2] = cons[3 * i + 2, 2] + newcons[5 * i + 3, 2] = cons[3 * i + 2, 2] + + if cons[3 * i + 2, 0] == CFACTOR: + # fwhm2 connstraint depends on fwhm1 + newcons[5 * i + 3, 1] = newcons[5 * i + 2, 1] + 1 + # eta constraints + newcons[5 * i + 4, 0] = CFREE + newcons[5 * i + 4, 1] = 0 + newcons[5 * i + 4, 2] = 0 + if self.config['QuotedEtaFlag']: + newcons[5 * i + 4, 0] = CQUOTED + newcons[5 * i + 4, 1] = 0.0 + newcons[5 * i + 4, 2] = 1.0 + return newpar, newcons + + def estimate_apvoigt(self, x, y): + """Estimation of *Area, Position, FWHM1, eta* of peaks, for + pseudo-Voigt curves. + + This functions uses :meth:`estimate_pvoigt`, then converts the height + parameter to area. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *Area, Position, FWHM, eta*. + """ + fittedpar, cons = self.estimate_pvoigt(x, y) + npeaks = len(fittedpar) // 4 + # Assume 50% of the area is determined by the gaussian and 50% by + # the Lorentzian. + for i in range(npeaks): + height = fittedpar[4 * i] + fwhm = fittedpar[4 * i + 2] + fittedpar[4 * i] = 0.5 * (height * fwhm * 0.5 * numpy.pi) +\ + 0.5 * (height * fwhm / (2.0 * numpy.sqrt(2 * numpy.log(2))) + ) * numpy.sqrt(2 * numpy.pi) + return fittedpar, cons + + def estimate_ahypermet(self, x, y): + """Estimation of *area, position, fwhm, st_area_r, st_slope_r, + lt_area_r, lt_slope_r, step_height_r* of peaks, for hypermet curves. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each peak are: + *area, position, fwhm, st_area_r, st_slope_r, + lt_area_r, lt_slope_r, step_height_r* . + """ + yscaling = self.config.get('Yscaling', 1.0) + if yscaling == 0: + yscaling = 1.0 + fittedpar, cons = self.estimate_height_position_fwhm(x, y) + npeaks = len(fittedpar) // 3 + newpar = [] + newcons = numpy.zeros((8 * npeaks, 3), numpy.float64) + main_peak = 0 + # find out related parameters proper index + if not self.config['NoConstraintsFlag']: + if self.config['SameFwhmFlag']: + j = 0 + # get the index of the free FWHM + for i in range(npeaks): + if cons[3 * i + 2, 0] != 4: + j = i + for i in range(npeaks): + if i != j: + cons[3 * i + 2, 1] = 8 * j + 2 + main_peak = j + for i in range(npeaks): + if fittedpar[3 * i] > fittedpar[3 * main_peak]: + main_peak = i + + for i in range(npeaks): + height = fittedpar[3 * i] + position = fittedpar[3 * i + 1] + fwhm = fittedpar[3 * i + 2] + area = (height * fwhm / (2.0 * numpy.sqrt(2 * numpy.log(2))) + ) * numpy.sqrt(2 * numpy.pi) + # the gaussian parameters + newpar.append(area) + newpar.append(position) + newpar.append(fwhm) + # print "area, pos , fwhm = ",area,position,fwhm + # Avoid zero derivatives because of not calculating contribution + g_term = 1 + st_term = 1 + lt_term = 1 + step_term = 1 + if self.config['HypermetTails'] != 0: + g_term = self.config['HypermetTails'] & 1 + st_term = (self.config['HypermetTails'] >> 1) & 1 + lt_term = (self.config['HypermetTails'] >> 2) & 1 + step_term = (self.config['HypermetTails'] >> 3) & 1 + if g_term == 0: + # fix the gaussian parameters + newcons[8 * i, 0] = CFIXED + newcons[8 * i + 1, 0] = CFIXED + newcons[8 * i + 2, 0] = CFIXED + # the short tail parameters + if ((area * yscaling) < + self.config['MinGaussArea4ShortTail']) | \ + (st_term == 0): + newpar.append(0.0) + newpar.append(0.0) + newcons[8 * i + 3, 0] = CFIXED + newcons[8 * i + 3, 1] = 0.0 + newcons[8 * i + 3, 2] = 0.0 + newcons[8 * i + 4, 0] = CFIXED + newcons[8 * i + 4, 1] = 0.0 + newcons[8 * i + 4, 2] = 0.0 + else: + newpar.append(self.config['InitialShortTailAreaRatio']) + newpar.append(self.config['InitialShortTailSlopeRatio']) + newcons[8 * i + 3, 0] = CQUOTED + newcons[8 * i + 3, 1] = self.config['MinShortTailAreaRatio'] + newcons[8 * i + 3, 2] = self.config['MaxShortTailAreaRatio'] + newcons[8 * i + 4, 0] = CQUOTED + newcons[8 * i + 4, 1] = self.config['MinShortTailSlopeRatio'] + newcons[8 * i + 4, 2] = self.config['MaxShortTailSlopeRatio'] + # the long tail parameters + if ((area * yscaling) < + self.config['MinGaussArea4LongTail']) | \ + (lt_term == 0): + newpar.append(0.0) + newpar.append(0.0) + newcons[8 * i + 5, 0] = CFIXED + newcons[8 * i + 5, 1] = 0.0 + newcons[8 * i + 5, 2] = 0.0 + newcons[8 * i + 6, 0] = CFIXED + newcons[8 * i + 6, 1] = 0.0 + newcons[8 * i + 6, 2] = 0.0 + else: + newpar.append(self.config['InitialLongTailAreaRatio']) + newpar.append(self.config['InitialLongTailSlopeRatio']) + newcons[8 * i + 5, 0] = CQUOTED + newcons[8 * i + 5, 1] = self.config['MinLongTailAreaRatio'] + newcons[8 * i + 5, 2] = self.config['MaxLongTailAreaRatio'] + newcons[8 * i + 6, 0] = CQUOTED + newcons[8 * i + 6, 1] = self.config['MinLongTailSlopeRatio'] + newcons[8 * i + 6, 2] = self.config['MaxLongTailSlopeRatio'] + # the step parameters + if ((height * yscaling) < + self.config['MinGaussHeight4StepTail']) | \ + (step_term == 0): + newpar.append(0.0) + newcons[8 * i + 7, 0] = CFIXED + newcons[8 * i + 7, 1] = 0.0 + newcons[8 * i + 7, 2] = 0.0 + else: + newpar.append(self.config['InitialStepTailHeightRatio']) + newcons[8 * i + 7, 0] = CQUOTED + newcons[8 * i + 7, 1] = self.config['MinStepTailHeightRatio'] + newcons[8 * i + 7, 2] = self.config['MaxStepTailHeightRatio'] + # if self.config['NoConstraintsFlag'] == 1: + # newcons=numpy.zeros((8*npeaks, 3),numpy.float64) + if npeaks > 0: + if g_term: + if self.config['PositiveHeightAreaFlag']: + for i in range(npeaks): + newcons[8 * i, 0] = CPOSITIVE + if self.config['PositiveFwhmFlag']: + for i in range(npeaks): + newcons[8 * i + 2, 0] = CPOSITIVE + if self.config['SameFwhmFlag']: + for i in range(npeaks): + if i != main_peak: + newcons[8 * i + 2, 0] = CFACTOR + newcons[8 * i + 2, 1] = 8 * main_peak + 2 + newcons[8 * i + 2, 2] = 1.0 + if self.config['HypermetQuotedPositionFlag']: + for i in range(npeaks): + delta = self.config['DeltaPositionFwhmUnits'] * fwhm + newcons[8 * i + 1, 0] = CQUOTED + newcons[8 * i + 1, 1] = newpar[8 * i + 1] - delta + newcons[8 * i + 1, 2] = newpar[8 * i + 1] + delta + if self.config['SameSlopeRatioFlag']: + for i in range(npeaks): + if i != main_peak: + newcons[8 * i + 4, 0] = CFACTOR + newcons[8 * i + 4, 1] = 8 * main_peak + 4 + newcons[8 * i + 4, 2] = 1.0 + newcons[8 * i + 6, 0] = CFACTOR + newcons[8 * i + 6, 1] = 8 * main_peak + 6 + newcons[8 * i + 6, 2] = 1.0 + if self.config['SameAreaRatioFlag']: + for i in range(npeaks): + if i != main_peak: + newcons[8 * i + 3, 0] = CFACTOR + newcons[8 * i + 3, 1] = 8 * main_peak + 3 + newcons[8 * i + 3, 2] = 1.0 + newcons[8 * i + 5, 0] = CFACTOR + newcons[8 * i + 5, 1] = 8 * main_peak + 5 + newcons[8 * i + 5, 2] = 1.0 + return newpar, newcons + + def estimate_stepdown(self, x, y): + """Estimation of parameters for stepdown curves. + + The functions estimates gaussian parameters for the derivative of + the data, takes the largest gaussian peak and uses its estimated + parameters to define the center of the step and its fwhm. The + estimated amplitude returned is simply ``max(y) - min(y)``. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit newconstraints. + Parameters to be estimated for each stepdown are: + *height, centroid, fwhm* . + """ + crappyfilter = [-0.25, -0.75, 0.0, 0.75, 0.25] + cutoff = len(crappyfilter) // 2 + y_deriv = numpy.convolve(y, + crappyfilter, + mode="valid") + + # make the derivative's peak have the same amplitude as the step + if max(y_deriv) > 0: + y_deriv = y_deriv * max(y) / max(y_deriv) + + fittedpar, newcons = self.estimate_height_position_fwhm( + x[cutoff:-cutoff], y_deriv) + + data_amplitude = max(y) - min(y) + + # use parameters from largest gaussian found + if len(fittedpar): + npeaks = len(fittedpar) // 3 + largest_index = 0 + largest = [data_amplitude, + fittedpar[3 * largest_index + 1], + fittedpar[3 * largest_index + 2]] + for i in range(npeaks): + if fittedpar[3 * i] > largest[0]: + largest_index = i + largest = [data_amplitude, + fittedpar[3 * largest_index + 1], + fittedpar[3 * largest_index + 2]] + else: + # no peak was found + largest = [data_amplitude, # height + x[len(x)//2], # center: middle of x range + self.config["FwhmPoints"] * (x[1] - x[0])] # fwhm: default value + + # Setup constrains + newcons = numpy.zeros((3, 3), numpy.float64) + if not self.config['NoConstraintsFlag']: + # Setup height constrains + if self.config['PositiveHeightAreaFlag']: + newcons[0, 0] = CPOSITIVE + newcons[0, 1] = 0 + newcons[0, 2] = 0 + + # Setup position constrains + if self.config['QuotedPositionFlag']: + newcons[1, 0] = CQUOTED + newcons[1, 1] = min(x) + newcons[1, 2] = max(x) + + # Setup positive FWHM constrains + if self.config['PositiveFwhmFlag']: + newcons[2, 0] = CPOSITIVE + newcons[2, 1] = 0 + newcons[2, 2] = 0 + + return largest, newcons + + def estimate_slit(self, x, y): + """Estimation of parameters for slit curves. + + The functions estimates stepup and stepdown parameters for the largest + steps, and uses them for calculating the center (middle between stepup + and stepdown), the height (maximum amplitude in data), the fwhm + (distance between the up- and down-step centers) and the beamfwhm + (average of FWHM for up- and down-step). + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each slit are: + *height, position, fwhm, beamfwhm* . + """ + largestup, cons = self.estimate_stepup(x, y) + largestdown, cons = self.estimate_stepdown(x, y) + fwhm = numpy.fabs(largestdown[1] - largestup[1]) + beamfwhm = 0.5 * (largestup[2] + largestdown[1]) + beamfwhm = min(beamfwhm, fwhm / 10.0) + beamfwhm = max(beamfwhm, (max(x) - min(x)) * 3.0 / len(x)) + + y_minus_bg = y - self.strip_bg(y) + height = max(y_minus_bg) + + i1 = numpy.nonzero(y_minus_bg >= 0.5 * height)[0] + xx = numpy.take(x, i1) + position = (xx[0] + xx[-1]) / 2.0 + fwhm = xx[-1] - xx[0] + largest = [height, position, fwhm, beamfwhm] + cons = numpy.zeros((4, 3), numpy.float64) + # Setup constrains + if not self.config['NoConstraintsFlag']: + # Setup height constrains + if self.config['PositiveHeightAreaFlag']: + cons[0, 0] = CPOSITIVE + cons[0, 1] = 0 + cons[0, 2] = 0 + + # Setup position constrains + if self.config['QuotedPositionFlag']: + cons[1, 0] = CQUOTED + cons[1, 1] = min(x) + cons[1, 2] = max(x) + + # Setup positive FWHM constrains + if self.config['PositiveFwhmFlag']: + cons[2, 0] = CPOSITIVE + cons[2, 1] = 0 + cons[2, 2] = 0 + + # Setup positive FWHM constrains + if self.config['PositiveFwhmFlag']: + cons[3, 0] = CPOSITIVE + cons[3, 1] = 0 + cons[3, 2] = 0 + return largest, cons + + def estimate_stepup(self, x, y): + """Estimation of parameters for a single step up curve. + + The functions estimates gaussian parameters for the derivative of + the data, takes the largest gaussian peak and uses its estimated + parameters to define the center of the step and its fwhm. The + estimated amplitude returned is simply ``max(y) - min(y)``. + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + Parameters to be estimated for each stepup are: + *height, centroid, fwhm* . + """ + crappyfilter = [0.25, 0.75, 0.0, -0.75, -0.25] + cutoff = len(crappyfilter) // 2 + y_deriv = numpy.convolve(y, crappyfilter, mode="valid") + if max(y_deriv) > 0: + y_deriv = y_deriv * max(y) / max(y_deriv) + + fittedpar, cons = self.estimate_height_position_fwhm( + x[cutoff:-cutoff], y_deriv) + + # for height, use the data amplitude after removing the background + data_amplitude = max(y) - min(y) + + # find params of the largest gaussian found + if len(fittedpar): + npeaks = len(fittedpar) // 3 + largest_index = 0 + largest = [data_amplitude, + fittedpar[3 * largest_index + 1], + fittedpar[3 * largest_index + 2]] + for i in range(npeaks): + if fittedpar[3 * i] > largest[0]: + largest_index = i + largest = [fittedpar[3 * largest_index], + fittedpar[3 * largest_index + 1], + fittedpar[3 * largest_index + 2]] + else: + # no peak was found + largest = [data_amplitude, # height + x[len(x)//2], # center: middle of x range + self.config["FwhmPoints"] * (x[1] - x[0])] # fwhm: default value + + newcons = numpy.zeros((3, 3), numpy.float64) + # Setup constrains + if not self.config['NoConstraintsFlag']: + # Setup height constraints + if self.config['PositiveHeightAreaFlag']: + newcons[0, 0] = CPOSITIVE + newcons[0, 1] = 0 + newcons[0, 2] = 0 + + # Setup position constraints + if self.config['QuotedPositionFlag']: + newcons[1, 0] = CQUOTED + newcons[1, 1] = min(x) + newcons[1, 2] = max(x) + + # Setup positive FWHM constraints + if self.config['PositiveFwhmFlag']: + newcons[2, 0] = CPOSITIVE + newcons[2, 1] = 0 + newcons[2, 2] = 0 + + return largest, newcons + + def estimate_periodic_gauss(self, x, y): + """Estimation of parameters for periodic gaussian curves: + *number of peaks, distance between peaks, height, position of the + first peak, fwhm* + + The functions detects all peaks, then computes the parameters the + following way: + + - *distance*: average of distances between detected peaks + - *height*: average height of detected peaks + - *fwhm*: fwhm of the highest peak (in number of samples) if + field ``'AutoFwhm'`` in :attr:`config` is ``True``, else take + the default value (field ``'FwhmPoints'`` in :attr:`config`) + + :param x: Array of abscissa values + :param y: Array of ordinate values (``y = f(x)``) + :return: Tuple of estimated fit parameters and fit constraints. + """ + yscaling = self.config.get('Yscaling', 1.0) + if yscaling == 0: + yscaling = 1.0 + + bg = self.strip_bg(y) + + if self.config['AutoFwhm']: + search_fwhm = guess_fwhm(y) + else: + search_fwhm = int(float(self.config['FwhmPoints'])) + search_sens = float(self.config['Sensitivity']) + + if search_fwhm < 3: + search_fwhm = 3 + + if search_sens < 1: + search_sens = 1 + + if len(y) > 1.5 * search_fwhm: + peaks = peak_search(yscaling * y, fwhm=search_fwhm, + sensitivity=search_sens) + else: + peaks = [] + npeaks = len(peaks) + if not npeaks: + fittedpar = [] + cons = numpy.zeros((len(fittedpar), 3), numpy.float64) + return fittedpar, cons + + fittedpar = [0.0, 0.0, 0.0, 0.0, 0.0] + + # The number of peaks + fittedpar[0] = npeaks + + # The separation between peaks in x units + delta = 0.0 + height = 0.0 + for i in range(npeaks): + height += y[int(peaks[i])] - bg[int(peaks[i])] + if i != npeaks - 1: + delta += (x[int(peaks[i + 1])] - x[int(peaks[i])]) + + # delta between peaks + if npeaks > 1: + fittedpar[1] = delta / (npeaks - 1) + + # starting height + fittedpar[2] = height / npeaks + + # position of the first peak + fittedpar[3] = x[int(peaks[0])] + + # Estimate the fwhm + fittedpar[4] = search_fwhm + + # setup constraints + cons = numpy.zeros((5, 3), numpy.float64) + cons[0, 0] = CFIXED # the number of gaussians + if npeaks == 1: + cons[1, 0] = CFIXED # the delta between peaks + else: + cons[1, 0] = CFREE + j = 2 + # Setup height area constrains + if not self.config['NoConstraintsFlag']: + if self.config['PositiveHeightAreaFlag']: + # POSITIVE = 1 + cons[j, 0] = CPOSITIVE + cons[j, 1] = 0 + cons[j, 2] = 0 + j += 1 + + # Setup position constrains + if not self.config['NoConstraintsFlag']: + if self.config['QuotedPositionFlag']: + # QUOTED = 2 + cons[j, 0] = CQUOTED + cons[j, 1] = min(x) + cons[j, 2] = max(x) + j += 1 + + # Setup positive FWHM constrains + if not self.config['NoConstraintsFlag']: + if self.config['PositiveFwhmFlag']: + # POSITIVE=1 + cons[j, 0] = CPOSITIVE + cons[j, 1] = 0 + cons[j, 2] = 0 + j += 1 + return fittedpar, cons + + def configure(self, **kw): + """Add new / unknown keyword arguments to :attr:`config`, + update entries in :attr:`config` if the parameter name is a existing + key. + + :param kw: Dictionary of keyword arguments. + :return: Configuration dictionary :attr:`config` + """ + if not kw.keys(): + return self.config + for key in kw.keys(): + notdone = 1 + # take care of lower / upper case problems ... + for config_key in self.config.keys(): + if config_key.lower() == key.lower(): + self.config[config_key] = kw[key] + notdone = 0 + if notdone: + self.config[key] = kw[key] + return self.config + +fitfuns = FitTheories() + +THEORY = OrderedDict(( + ('Gaussians', + FitTheory(description='Gaussian functions', + function=functions.sum_gauss, + parameters=('Height', 'Position', 'FWHM'), + estimate=fitfuns.estimate_height_position_fwhm, + configure=fitfuns.configure)), + ('Lorentz', + FitTheory(description='Lorentzian functions', + function=functions.sum_lorentz, + parameters=('Height', 'Position', 'FWHM'), + estimate=fitfuns.estimate_height_position_fwhm, + configure=fitfuns.configure)), + ('Area Gaussians', + FitTheory(description='Gaussian functions (area)', + function=functions.sum_agauss, + parameters=('Area', 'Position', 'FWHM'), + estimate=fitfuns.estimate_agauss, + configure=fitfuns.configure)), + ('Area Lorentz', + FitTheory(description='Lorentzian functions (area)', + function=functions.sum_alorentz, + parameters=('Area', 'Position', 'FWHM'), + estimate=fitfuns.estimate_alorentz, + configure=fitfuns.configure)), + ('Pseudo-Voigt Line', + FitTheory(description='Pseudo-Voigt functions', + function=functions.sum_pvoigt, + parameters=('Height', 'Position', 'FWHM', 'Eta'), + estimate=fitfuns.estimate_pvoigt, + configure=fitfuns.configure)), + ('Area Pseudo-Voigt', + FitTheory(description='Pseudo-Voigt functions (area)', + function=functions.sum_apvoigt, + parameters=('Area', 'Position', 'FWHM', 'Eta'), + estimate=fitfuns.estimate_apvoigt, + configure=fitfuns.configure)), + ('Split Gaussian', + FitTheory(description='Asymmetric gaussian functions', + function=functions.sum_splitgauss, + parameters=('Height', 'Position', 'LowFWHM', + 'HighFWHM'), + estimate=fitfuns.estimate_splitgauss, + configure=fitfuns.configure)), + ('Split Lorentz', + FitTheory(description='Asymmetric lorentzian functions', + function=functions.sum_splitlorentz, + parameters=('Height', 'Position', 'LowFWHM', 'HighFWHM'), + estimate=fitfuns.estimate_splitgauss, + configure=fitfuns.configure)), + ('Split Pseudo-Voigt', + FitTheory(description='Asymmetric pseudo-Voigt functions', + function=functions.sum_splitpvoigt, + parameters=('Height', 'Position', 'LowFWHM', + 'HighFWHM', 'Eta'), + estimate=fitfuns.estimate_splitpvoigt, + configure=fitfuns.configure)), + ('Step Down', + FitTheory(description='Step down function', + function=functions.sum_stepdown, + parameters=('Height', 'Position', 'FWHM'), + estimate=fitfuns.estimate_stepdown, + configure=fitfuns.configure)), + ('Step Up', + FitTheory(description='Step up function', + function=functions.sum_stepup, + parameters=('Height', 'Position', 'FWHM'), + estimate=fitfuns.estimate_stepup, + configure=fitfuns.configure)), + ('Slit', + FitTheory(description='Slit function', + function=functions.sum_slit, + parameters=('Height', 'Position', 'FWHM', 'BeamFWHM'), + estimate=fitfuns.estimate_slit, + configure=fitfuns.configure)), + ('Atan', + FitTheory(description='Arctan step up function', + function=functions.atan_stepup, + parameters=('Height', 'Position', 'Width'), + estimate=fitfuns.estimate_stepup, + configure=fitfuns.configure)), + ('Hypermet', + FitTheory(description='Hypermet functions', + function=fitfuns.ahypermet, # customized version of functions.sum_ahypermet + parameters=('G_Area', 'Position', 'FWHM', 'ST_Area', + 'ST_Slope', 'LT_Area', 'LT_Slope', 'Step_H'), + estimate=fitfuns.estimate_ahypermet, + configure=fitfuns.configure)), + # ('Periodic Gaussians', + # FitTheory(description='Periodic gaussian functions', + # function=functions.periodic_gauss, + # parameters=('N', 'Delta', 'Height', 'Position', 'FWHM'), + # estimate=fitfuns.estimate_periodic_gauss, + # configure=fitfuns.configure)) + ('Degree 2 Polynomial', + FitTheory(description='Degree 2 polynomial' + '\ny = a*x^2 + b*x +c', + function=fitfuns.poly, + parameters=['a', 'b', 'c'], + estimate=fitfuns.estimate_quadratic)), + ('Degree 3 Polynomial', + FitTheory(description='Degree 3 polynomial' + '\ny = a*x^3 + b*x^2 + c*x + d', + function=fitfuns.poly, + parameters=['a', 'b', 'c', 'd'], + estimate=fitfuns.estimate_cubic)), + ('Degree 4 Polynomial', + FitTheory(description='Degree 4 polynomial' + '\ny = a*x^4 + b*x^3 + c*x^2 + d*x + e', + function=fitfuns.poly, + parameters=['a', 'b', 'c', 'd', 'e'], + estimate=fitfuns.estimate_quartic)), + ('Degree 5 Polynomial', + FitTheory(description='Degree 5 polynomial' + '\ny = a*x^5 + b*x^4 + c*x^3 + d*x^2 + e*x + f', + function=fitfuns.poly, + parameters=['a', 'b', 'c', 'd', 'e', 'f'], + estimate=fitfuns.estimate_quintic)), +)) +"""Dictionary of fit theories: fit functions and their associated estimation +function, parameters list, configuration function and description. +""" + + +def test(a): + from silx.math.fit import fitmanager + x = numpy.arange(1000).astype(numpy.float64) + p = [1500, 100., 50.0, + 1500, 700., 50.0] + y_synthetic = functions.sum_gauss(x, *p) + 1 + + fit = fitmanager.FitManager(x, y_synthetic) + fit.addtheory('Gaussians', functions.sum_gauss, ['Height', 'Position', 'FWHM'], + a.estimate_height_position_fwhm) + fit.settheory('Gaussians') + fit.setbackground('Linear') + + fit.estimate() + fit.runfit() + + y_fit = fit.gendata() + + print("Fit parameter names: %s" % str(fit.get_names())) + print("Theoretical parameters: %s" % str(numpy.append([1, 0], p))) + print("Fitted parameters: %s" % str(fit.get_fitted_parameters())) + + try: + from silx.gui import qt + from silx.gui.plot import plot1D + app = qt.QApplication([]) + + # Offset of 1 to see the difference in log scale + plot1D(x, (y_synthetic + 1, y_fit), "Input data + 1, Fit") + + app.exec() + except ImportError: + _logger.warning("Unable to load qt binding, can't plot results.") + + +if __name__ == "__main__": + test(fitfuns) diff --git a/src/silx/math/fit/fittheory.py b/src/silx/math/fit/fittheory.py new file mode 100644 index 0000000..fa42e6b --- /dev/null +++ b/src/silx/math/fit/fittheory.py @@ -0,0 +1,161 @@ +# coding: utf-8 +#/*########################################################################## +# +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +########################################################################### */ +""" +This module defines the :class:`FitTheory` object that is used by +:class:`silx.math.fit.FitManager` to define fit functions and background +models. +""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "09/08/2016" + + +class FitTheory(object): + """This class defines a fit theory, which consists of: + + - a model function, the actual function to be fitted + - parameters names + - an estimation function, that return the estimated initial parameters + that serve as input for :func:`silx.math.fit.leastsq` + - an optional configuration function, that can be used to modify + configuration parameters to alter the behavior of the fit function + and the estimation function + - an optional derivative function, that replaces the default model + derivative used in :func:`silx.math.fit.leastsq` + """ + def __init__(self, function, parameters, + estimate=None, configure=None, derivative=None, + description=None, pymca_legacy=False, is_background=False): + """ + :param function function: Actual function. See documentation for + :attr:`function`. + :param list[str] parameters: List of parameter names for the function. + See documentation for :attr:`parameters`. + :param function estimate: Optional estimation function. + See documentation for :attr:`estimate` + :param function configure: Optional configuration function. + See documentation for :attr:`configure` + :param function derivative: Optional custom derivative function. + See documentation for :attr:`derivative` + :param str description: Optional description string. + See documentation for :attr:`description` + :param bool pymca_legacy: Flag to indicate that the theory is a PyMca + legacy theory. See documentation for :attr:`pymca_legacy` + :param bool is_background: Flag to indicate that the theory is a + background theory. This has implications regarding the function's + signature, as explained in the documentation for :attr:`function`. + """ + self.function = function + """Regular fit functions must have the signature ``f(x, *params) -> y``, + where *x* is a 1D array of values for the independent variable, + *params* are the parameters to be fitted and *y* is the output array + that we want to have the best fit to a series of data points. + + Background functions used by :class:`FitManager` must have a slightly + different signature: ``f(x, y0, *params) -> bg``, where *y0* is the + array of original data points and *bg* is the background signal that + we want to subtract from the data array prior to fitting the regular + fit function. + + The number of parameters must be the same as in :attr:`parameters`, or + a multiple of this number if the function is defined as a sum of a + variable number of base functions and if :attr:`estimate` is designed + to be able to estimate the number of needed base functions. + """ + + self.parameters = parameters + """List of parameters names. + + This list can contain the minimum number of parameters, if the + function takes a variable number of parameters, + and if the estimation function is responsible for finding the number + of required parameters """ + + self.estimate = estimate + """The estimation function should have the following signature:: + + f(x, y) -> (estimated_param, constraints) + + Parameters: + + - ``x`` is a sequence of values for the independent variable + - ``y`` is a sequence of the same length as ``x`` containing the + data to be fitted + + Return values: + + - ``estimated_param`` is a sequence of estimated fit parameters to + be used as initial values for an iterative fit. + - ``constraints`` is a sequence of shape *(n, 3)*, where *n* is the + number of estimated parameters, containing the constraints for each + parameter to be fitted. See :func:`silx.math.fit.leastsq` for more + explanations about constraints.""" + if estimate is None: + self.estimate = self.default_estimate + + self.configure = configure + """The optional configuration function must conform to the signature + ``f(**kw) -> dict`` (i.e it must accept any named argument and + return a dictionary). + It can be used to modify configuration parameters to alter the + behavior of the fit function and the estimation function.""" + + self.derivative = derivative + """The optional derivative function must conform to the signature + ``model_deriv(xdata, parameters, index)``, where parameters is a + sequence with the current values of the fitting parameters, index is + the fitting parameter index for which the the derivative has to be + provided in the supplied array of xdata points.""" + + self.description = description + """Optional description string for this particular fit theory.""" + + self.pymca_legacy = pymca_legacy + """This attribute can be set to *True* to indicate that the theory + is a PyMca legacy theory. + + This tells :mod:`silx.math.fit.fitmanager` that the signature of + the estimate function is:: + + f(x, y, bg, xscaling, yscaling) -> (estimated_param, constraints) + """ + + self.is_background = is_background + """Flag to indicate that the theory is background theory. + + A background function is an secondary function that needs to be added + to the main fit function to better fit the original data. + If this flag is set to *True*, modules using this theory are informed + that :attr:`function` has the signature ``f(x, y0, *params) -> bg``, + instead of the usual fit function signature.""" + + def default_estimate(self, x=None, y=None, bg=None): + """Default estimate function. Return an array of *ones* as the + initial estimated parameters, and set all constraints to zero + (FREE)""" + estimated_parameters = [1. for _ in self.parameters] + estimated_constraints = [[0, 0, 0] for _ in self.parameters] + return estimated_parameters, estimated_constraints diff --git a/src/silx/math/fit/functions.pyx b/src/silx/math/fit/functions.pyx new file mode 100644 index 0000000..1f78563 --- /dev/null +++ b/src/silx/math/fit/functions.pyx @@ -0,0 +1,985 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +"""This module provides fit functions. + +List of fit functions: +----------------------- + + - :func:`sum_gauss` + - :func:`sum_agauss` + - :func:`sum_splitgauss` + - :func:`sum_fastagauss` + + - :func:`sum_apvoigt` + - :func:`sum_pvoigt` + - :func:`sum_splitpvoigt` + + - :func:`sum_lorentz` + - :func:`sum_alorentz` + - :func:`sum_splitlorentz` + + - :func:`sum_stepdown` + - :func:`sum_stepup` + - :func:`sum_slit` + + - :func:`sum_ahypermet` + - :func:`sum_fastahypermet` + +Full documentation: +------------------- + +""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "16/08/2017" + +import logging +import numpy + +_logger = logging.getLogger(__name__) + +cimport cython +cimport silx.math.fit.functions_wrapper as functions_wrapper + + +def erf(x): + """Return the gaussian error function + + :param x: Independent variable where the gaussian error function is + calculated + :type x: numpy.ndarray or scalar + :return: Gaussian error function ``y=erf(x)`` + :raise: IndexError if ``x`` is an empty array + """ + cdef: + double[::1] x_c + double[::1] y_c + + + # force list into numpy array + if not hasattr(x, "shape"): + x = numpy.asarray(x) + + for len_dim in x.shape: + if len_dim == 0: + raise IndexError("Cannot compute erf for an empty array") + + x_c = numpy.array(x, copy=False, dtype=numpy.float64, order='C').reshape(-1) + y_c = numpy.empty(shape=(x_c.size,), dtype=numpy.float64) + + status = functions_wrapper.erf_array(&x_c[0], x_c.size, &y_c[0]) + + return numpy.asarray(y_c).reshape(x.shape) + + +def erfc(x): + """Return the gaussian complementary error function + + :param x: Independent variable where the gaussian complementary error + function is calculated + :type x: numpy.ndarray or scalar + :return: Gaussian complementary error function ``y=erfc(x)`` + :type rtype: numpy.ndarray + :raise: IndexError if ``x`` is an empty array + """ + cdef: + double[::1] x_c + double[::1] y_c + + # force list into numpy array + if not hasattr(x, "shape"): + x = numpy.asarray(x) + + for len_dim in x.shape: + if len_dim == 0: + raise IndexError("Cannot compute erfc for an empty array") + + x_c = numpy.array(x, copy=False, dtype=numpy.float64, order='C').reshape(-1) + y_c = numpy.empty(shape=(x_c.size,), dtype=numpy.float64) + + status = functions_wrapper.erfc_array(&x_c[0], x_c.size, &y_c[0]) + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_gauss(x, *params): + """Return a sum of gaussian functions defined by *(height, centroid, fwhm)*, + where: + + - *height* is the peak amplitude + - *centroid* is the peak x-coordinate + - *fwhm* is the full-width at half maximum + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of gaussian parameters (length must be a multiple + of 3): + *(height1, centroid1, fwhm1, height2, centroid2, fwhm2,...)* + :return: Array of sum of gaussian functions at each ``x`` coordinate. + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No gaussian parameters specified. " + + "At least 3 parameters are required.") + + # ensure float64 (double) type and 1D contiguous data layout in memory + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_gauss( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + # reshape y_c to match original, possibly unusual, data shape + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_agauss(x, *params): + """Return a sum of gaussian functions defined by *(area, centroid, fwhm)*, + where: + + - *area* is the area underneath the peak + - *centroid* is the peak x-coordinate + - *fwhm* is the full-width at half maximum + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of gaussian parameters (length must be a multiple + of 3): + *(area1, centroid1, fwhm1, area2, centroid2, fwhm2,...)* + :return: Array of sum of gaussian functions at each ``x`` coordinate. + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No gaussian parameters specified. " + + "At least 3 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_agauss( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_fastagauss(x, *params): + """Return a sum of gaussian functions defined by *(area, centroid, fwhm)*, + where: + + - *area* is the area underneath the peak + - *centroid* is the peak x-coordinate + - *fwhm* is the full-width at half maximum + + This implementation differs from :func:`sum_agauss` by the usage of a + lookup table with precalculated exponential values. This might speed up + the computation for large numbers of individual gaussian functions. + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of gaussian parameters (length must be a multiple + of 3): + *(area1, centroid1, fwhm1, area2, centroid2, fwhm2,...)* + :return: Array of sum of gaussian functions at each ``x`` coordinate. + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No gaussian parameters specified. " + + "At least 3 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_fastagauss( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_splitgauss(x, *params): + """Return a sum of gaussian functions defined by *(area, centroid, fwhm1, fwhm2)*, + where: + + - *height* is the peak amplitude + - *centroid* is the peak x-coordinate + - *fwhm1* is the full-width at half maximum for the distribution + when ``x < centroid`` + - *fwhm2* is the full-width at half maximum for the distribution + when ``x > centroid`` + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of gaussian parameters (length must be a multiple + of 4): + *(height1, centroid1, fwhm11, fwhm21, height2, centroid2, fwhm12, fwhm22,...)* + :return: Array of sum of split gaussian functions at each ``x`` coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No gaussian parameters specified. " + + "At least 4 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_splitgauss( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_apvoigt(x, *params): + """Return a sum of pseudo-Voigt functions, defined by *(area, centroid, fwhm, + eta)*. + + The pseudo-Voigt profile ``PV(x)`` is an approximation of the Voigt + profile using a linear combination of a Gaussian curve ``G(x)`` and a + Lorentzian curve ``L(x)`` instead of their convolution. + + - *area* is the area underneath both G(x) and L(x) + - *centroid* is the peak x-coordinate for both functions + - *fwhm* is the full-width at half maximum of both functions + - *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of pseudo-Voigt parameters (length must be a multiple + of 4): + *(area1, centroid1, fwhm1, eta1, area2, centroid2, fwhm2, eta2,...)* + :return: Array of sum of pseudo-Voigt functions at each ``x`` coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 4 parameters are required.") + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_apvoigt( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_pvoigt(x, *params): + """Return a sum of pseudo-Voigt functions, defined by *(height, centroid, + fwhm, eta)*. + + The pseudo-Voigt profile ``PV(x)`` is an approximation of the Voigt + profile using a linear combination of a Gaussian curve ``G(x)`` and a + Lorentzian curve ``L(x)`` instead of their convolution. + + - *height* is the peak amplitude of G(x) and L(x) + - *centroid* is the peak x-coordinate for both functions + - *fwhm* is the full-width at half maximum of both functions + - *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of pseudo-Voigt parameters (length must be a multiple + of 4): + *(height1, centroid1, fwhm1, eta1, height2, centroid2, fwhm2, eta2,...)* + :return: Array of sum of pseudo-Voigt functions at each ``x`` coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 4 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_pvoigt( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_splitpvoigt(x, *params): + """Return a sum of split pseudo-Voigt functions, defined by *(height, + centroid, fwhm1, fwhm2, eta)*. + + The pseudo-Voigt profile ``PV(x)`` is an approximation of the Voigt + profile using a linear combination of a Gaussian curve ``G(x)`` and a + Lorentzian curve ``L(x)`` instead of their convolution. + + - *height* is the peak amplitudefor G(x) and L(x) + - *centroid* is the peak x-coordinate for both functions + - *fwhm1* is the full-width at half maximum of both functions + when ``x < centroid`` + - *fwhm2* is the full-width at half maximum of both functions + when ``x > centroid`` + - *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of pseudo-Voigt parameters (length must be a multiple + of 5): + *(height1, centroid1, fwhm11, fwhm21, eta1,...)* + :return: Array of sum of split pseudo-Voigt functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 5 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_splitpvoigt( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_lorentz(x, *params): + """Return a sum of Lorentz distributions, also known as Cauchy distribution, + defined by *(height, centroid, fwhm)*. + + - *height* is the peak amplitude + - *centroid* is the peak x-coordinate + - *fwhm* is the full-width at half maximum + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of Lorentz parameters (length must be a multiple + of 3): + *(height1, centroid1, fwhm1,...)* + :return: Array of sum Lorentz functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 3 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_lorentz( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_alorentz(x, *params): + """Return a sum of Lorentz distributions, also known as Cauchy distribution, + defined by *(area, centroid, fwhm)*. + + - *area* is the area underneath the peak + - *centroid* is the peak x-coordinate for both functions + - *fwhm* is the full-width at half maximum + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of Lorentz parameters (length must be a multiple + of 3): + *(area1, centroid1, fwhm1,...)* + :return: Array of sum of Lorentz functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 3 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_alorentz( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_splitlorentz(x, *params): + """Return a sum of split Lorentz distributions, + defined by *(height, centroid, fwhm1, fwhm2)*. + + - *height* is the peak amplitude + - *centroid* is the peak x-coordinate for both functions + - *fwhm1* is the full-width at half maximum for ``x < centroid`` + - *fwhm2* is the full-width at half maximum for ``x > centroid`` + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of Lorentz parameters (length must be a multiple + of 4): + *(height1, centroid1, fwhm11, fwhm21...)* + :return: Array of sum of Lorentz functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 4 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_splitlorentz( + &x_c[0], x.size, + ¶ms_c[0], params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_stepdown(x, *params): + """Return a sum of stepdown functions. + defined by *(height, centroid, fwhm)*. + + - *height* is the step's amplitude + - *centroid* is the step's x-coordinate + - *fwhm* is the full-width at half maximum for the derivative, + which is a measure of the *sharpness* of the step-down's edge + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of stepdown parameters (length must be a multiple + of 3): + *(height1, centroid1, fwhm1,...)* + :return: Array of sum of stepdown functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 3 parameters are required.") + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_stepdown(&x_c[0], + x.size, + ¶ms_c[0], + params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_stepup(x, *params): + """Return a sum of stepup functions. + defined by *(height, centroid, fwhm)*. + + - *height* is the step's amplitude + - *centroid* is the step's x-coordinate + - *fwhm* is the full-width at half maximum for the derivative, + which is a measure of the *sharpness* of the step-up's edge + + :param x: Independent variable where the gaussians are calculated + :type x: numpy.ndarray + :param params: Array of stepup parameters (length must be a multiple + of 3): + *(height1, centroid1, fwhm1,...)* + :return: Array of sum of stepup functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 3 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_stepup(&x_c[0], + x.size, + ¶ms_c[0], + params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_slit(x, *params): + """Return a sum of slit functions. + defined by *(height, position, fwhm, beamfwhm)*. + + - *height* is the slit's amplitude + - *position* is the center of the slit's x-coordinate + - *fwhm* is the full-width at half maximum of the slit + - *beamfwhm* is the full-width at half maximum of the + derivative, which is a measure of the *sharpness* + of the edges of the slit + + :param x: Independent variable where the slits are calculated + :type x: numpy.ndarray + :param params: Array of slit parameters (length must be a multiple + of 4): + *(height1, centroid1, fwhm1, beamfwhm1,...)* + :return: Array of sum of slit functions at each ``x`` + coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 4 parameters are required.") + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_slit(&x_c[0], + x.size, + ¶ms_c[0], + params_c.size, + &y_c[0]) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_ahypermet(x, *params, + gaussian_term=True, st_term=True, lt_term=True, step_term=True): + """Return a sum of ahypermet functions. + defined by *(area, position, fwhm, st_area_r, st_slope_r, lt_area_r, + lt_slope_r, step_height_r)*. + + - *area* is the area underneath the gaussian peak + - *position* is the center of the various peaks and the position of + the step down + - *fwhm* is the full-width at half maximum of the terms + - *st_area_r* is factor between the gaussian area and the area of the + short tail term + - *st_slope_r* is a ratio related to the slope of the short tail + in the low ``x`` values (the lower, the steeper) + - *lt_area_r* is ratio between the gaussian area and the area of the + long tail term + - *lt_slope_r* is a ratio related to the slope of the long tail + in the low ``x`` values (the lower, the steeper) + - *step_height_r* is the ratio between the height of the step down + and the gaussian height + + A hypermet function is a sum of four functions (terms): + + - a gaussian term + - a long tail term + - a short tail term + - a step down term + + :param x: Independent variable where the hypermets are calculated + :type x: numpy.ndarray + :param params: Array of hypermet parameters (length must be a multiple + of 8): + *(area1, position1, fwhm1, st_area_r1, st_slope_r1, lt_area_r1, + lt_slope_r1, step_height_r1...)* + :param gaussian_term: If ``True``, enable gaussian term. Default ``True`` + :param st_term: If ``True``, enable gaussian term. Default ``True`` + :param lt_term: If ``True``, enable gaussian term. Default ``True`` + :param step_term: If ``True``, enable gaussian term. Default ``True`` + :return: Array of sum of hypermet functions at each ``x`` coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 8 parameters are required.") + + # Sum binary flags to activate various terms of the equation + tail_flags = 1 if gaussian_term else 0 + if st_term: + tail_flags += 2 + if lt_term: + tail_flags += 4 + if step_term: + tail_flags += 8 + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_ahypermet(&x_c[0], + x.size, + ¶ms_c[0], + params_c.size, + &y_c[0], + tail_flags) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def sum_fastahypermet(x, *params, + gaussian_term=True, st_term=True, + lt_term=True, step_term=True): + """Return a sum of hypermet functions defined by *(area, position, fwhm, + st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r)*. + + - *area* is the area underneath the gaussian peak + - *position* is the center of the various peaks and the position of + the step down + - *fwhm* is the full-width at half maximum of the terms + - *st_area_r* is factor between the gaussian area and the area of the + short tail term + - *st_slope_r* is a parameter related to the slope of the short tail + in the low ``x`` values (the lower, the steeper) + - *lt_area_r* is factor between the gaussian area and the area of the + long tail term + - *lt_slope_r* is a parameter related to the slope of the long tail + in the low ``x`` values (the lower, the steeper) + - *step_height_r* is the factor between the height of the step down + and the gaussian height + + A hypermet function is a sum of four functions (terms): + + - a gaussian term + - a long tail term + - a short tail term + - a step down term + + This function differs from :func:`sum_ahypermet` by the use of a lookup + table for calculating exponentials. This offers better performance when + calculating many functions for large ``x`` arrays. + + :param x: Independent variable where the hypermets are calculated + :type x: numpy.ndarray + :param params: Array of hypermet parameters (length must be a multiple + of 8): + *(area1, position1, fwhm1, st_area_r1, st_slope_r1, lt_area_r1, + lt_slope_r1, step_height_r1...)* + :param gaussian_term: If ``True``, enable gaussian term. Default ``True`` + :param st_term: If ``True``, enable gaussian term. Default ``True`` + :param lt_term: If ``True``, enable gaussian term. Default ``True`` + :param step_term: If ``True``, enable gaussian term. Default ``True`` + :return: Array of sum of hypermet functions at each ``x`` coordinate + """ + cdef: + double[::1] x_c + double[::1] params_c + double[::1] y_c + + if not len(params): + raise IndexError("No parameters specified. " + + "At least 8 parameters are required.") + + # Sum binary flags to activate various terms of the equation + tail_flags = 1 if gaussian_term else 0 + if st_term: + tail_flags += 2 + if lt_term: + tail_flags += 4 + if step_term: + tail_flags += 8 + + # TODO (maybe): + # Set flags according to params, to move conditional + # branches out of the C code. + # E.g., set st_term = False if any of the st_slope_r params + # (params[8*i + 4]) is 0, to prevent division by 0. Same thing for + # lt_slope_r (params[8*i + 6]) and lt_term. + + x_c = numpy.array(x, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + params_c = numpy.array(params, + copy=False, + dtype=numpy.float64, + order='C').reshape(-1) + y_c = numpy.empty(shape=(x.size,), + dtype=numpy.float64) + + status = functions_wrapper.sum_fastahypermet(&x_c[0], + x.size, + ¶ms_c[0], + params_c.size, + &y_c[0], + tail_flags) + + if status: + raise IndexError("Wrong number of parameters for function") + + return numpy.asarray(y_c).reshape(x.shape) + + +def atan_stepup(x, a, b, c): + """ + Step up function using an inverse tangent. + + :param x: Independent variable where the function is calculated + :type x: numpy array + :param a: Height of the step up + :param b: Center of the step up + :param c: Parameter related to the slope of the step. A lower ``c`` + value yields a sharper step. + :return: ``a * (0.5 + (arctan((x - b) / c) / pi))`` + :rtype: numpy array + """ + if not hasattr(x, "shape"): + x = numpy.array(x) + return a * (0.5 + (numpy.arctan((1.0 * x - b) / c) / numpy.pi)) + + +def periodic_gauss(x, *pars): + """ + Return a sum of gaussian functions defined by + *(npeaks, delta, height, centroid, fwhm)*, + where: + + - *npeaks* is the number of gaussians peaks + - *delta* is the constant distance between 2 peaks + - *height* is the peak amplitude of all the gaussians + - *centroid* is the peak x-coordinate of the first gaussian + - *fwhm* is the full-width at half maximum for all the gaussians + + :param x: Independent variable where the function is calculated + :param pars: *(npeaks, delta, height, centroid, fwhm)* + :return: Sum of ``npeaks`` gaussians + """ + + if not len(pars): + raise IndexError("No parameters specified. " + + "At least 5 parameters are required.") + + newpars = numpy.zeros((pars[0], 3), numpy.float64) + for i in range(int(pars[0])): + newpars[i, 0] = pars[2] + newpars[i, 1] = pars[3] + i * pars[1] + newpars[:, 2] = pars[4] + return sum_gauss(x, newpars) diff --git a/src/silx/math/fit/functions/include/functions.h b/src/silx/math/fit/functions/include/functions.h new file mode 100644 index 0000000..de4209b --- /dev/null +++ b/src/silx/math/fit/functions/include/functions.h @@ -0,0 +1,68 @@ +/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +#ifndef FITFUNCTIONS_H +#define FITFUNCTIONS_H + +/* Helper functions */ +int test_params(int len_params, int len_params_one_function, char* fun_name, char* param_names); +double myerfc(double x); +double myerf(double x); +int erfc_array(double* x, int len_x, double* y); +int erf_array(double* x, int len_x, double* y); + +/* Background functions */ +void snip1d(double *data, int size, int width); +//void snip1d_multiple(double *data, int n_channels, int snip_width, int n_spectra); +void snip2d(double *data, int nrows, int ncolumns, int width); +void snip3d(double *data, int nx, int ny, int nz, int width); + +int strip(double* input, long len_input, double c, long niter, int deltai, + long* anchors, long len_anchors, double* output); + +/* Smoothing functions */ + +int SavitskyGolay(double* input, long len_input, int npoints, double* output); + +/* Fit functions */ +int sum_gauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y); +int sum_agauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y); +int sum_fastagauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y); +int sum_splitgauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y); + +int sum_apvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y); +int sum_pvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y); +int sum_splitpvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y); + +int sum_lorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y); +int sum_alorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y); +int sum_splitlorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y); + +int sum_stepdown(double* x, int len_x, double* pdstep, int len_pdstep, double* y); +int sum_stepup(double* x, int len_x, double* pustep, int len_pustep, double* y); +int sum_slit(double* x, int len_x, double* pslit, int len_pslit, double* y); + +int sum_ahypermet(double* x, int len_x, double* phypermet, int len_phypermet, double* y, int tail_flags); +int sum_fastahypermet(double* x, int len_x, double* phypermet, int len_phypermet, double* y, int tail_flags); + +#endif /* #define FITFUNCTIONS_H */ diff --git a/src/silx/math/fit/functions/src/funs.c b/src/silx/math/fit/functions/src/funs.c new file mode 100644 index 0000000..aae173f --- /dev/null +++ b/src/silx/math/fit/functions/src/funs.c @@ -0,0 +1,1265 @@ +#/*########################################################################## +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +/* + This file provides fit functions. + + It is adapted from PyMca source file "SpecFitFuns.c". The main difference + with the original code is that this code does not handle the python + wrapping, which is done elsewhere using cython. + + Authors: V.A. Sole, P. Knobel + License: MIT + Last modified: 17/06/2016 +*/ +#include <math.h> +#include <stdlib.h> +#include <stdio.h> +#include "functions.h" + +#ifndef M_PI +#define M_PI 3.1415926535 +#endif + +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +#if defined(_WIN32) +#define erf myerf +#define erfc myerfc +#endif + +#define LOG2 0.69314718055994529 + + +int test_params(int len_params, + int len_params_one_function, + char* fun_name, + char* param_names) +{ + if (len_params % len_params_one_function) { + printf("[%s]Error: Number of parameters must be a multiple of %d.", + fun_name, len_params_one_function); + printf("\nParameters expected for %s: %s\n", + fun_name, param_names); + return(1); + } + if (len_params == 0) { + printf("[%s]Error: No parameters specified.", fun_name); + printf("\nParameters expected for %s: %s\n", + fun_name, param_names); + return(1); + } + return(0); +} + +/* Complementary error function for a single value*/ +double myerfc(double x) +{ + double z; + double t; + double r; + + z=fabs(x); + t=1.0/(1.0+0.5*z); + r=t * exp(-z * z - 1.26551223 + t * (1.00002368 + t * (0.3740916 + + t * (0.09678418 + t * (-0.18628806 + t * (0.27886807 + t * (-1.13520398 + + t * (1.48851587 + t * (-0.82215223+t*0.17087277))))))))); + if (x<0) + r=2.0-r; + return (r); +} + +/* Gauss error function for a single value*/ +double myerf(double x) +{ + return (1.0 - myerfc(x)); +} + +/* Gauss error function for an array + y[i]=erf(x[i]) + returns status code 0 +*/ +int erf_array(double* x, int len_x, double* y) +{ + int j; + for (j=0; j<len_x; j++) { + y[j] = erf(x[j]); + } + return(0); +} + +/* Complementary error function for an array + y[i]=erfc(x[i]) + returns status code 0*/ +int erfc_array(double* x, int len_x, double* y) +{ + int j; + for (j=0; j<len_x; j++) { + y[j] = erfc(x[j]); + } + return(0); +} + +/* Use lookup table for fast exp computation */ +double fastexp(double x) +{ + int expindex; + static double EXP[5000] = {0.0}; + int i; + +/*initialize */ + if (EXP[0] < 1){ + for (i=0;i<5000;i++){ + EXP[i] = exp(-0.01 * i); + } + } +/*calculate*/ + if (x < 0){ + x = -x; + if (x < 50){ + expindex = (int) (x * 100); + return EXP[expindex]*(1.0 - (x - 0.01 * expindex)) ; + }else if (x < 100) { + expindex = (int) (x * 10); + return pow(EXP[expindex]*(1.0 - (x - 0.1 * expindex)),10) ; + }else if (x < 1000){ + expindex = (int) x; + return pow(EXP[expindex]*(1.0 - (x - expindex)),20) ; + }else if (x < 10000){ + expindex = (int) (x * 0.1); + return pow(EXP[expindex]*(1.0 - (x - 10.0 * expindex)),30) ; + }else{ + return 0; + } + }else{ + if (x < 50){ + expindex = (int) (x * 100); + return 1.0/EXP[expindex]*(1.0 - (x - 0.01 * expindex)) ; + }else if (x < 100) { + expindex = (int) (x * 10); + return pow(EXP[expindex]*(1.0 - (x - 0.1 * expindex)),-10) ; + }else{ + return exp(x); + } + } +} + + +/* sum_gauss + Sum of gaussian functions, defined by (height, centroid, fwhm) + + *height* is the peak amplitude + *centroid* is the peak x-coordinate + *fwhm* is the full-width at half maximum + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pvoigt: Array of gaussian parameters: + (height1, centroid1, fwhm1, height2, centroid2, fwhm2,...) + - len_pgauss: Number of elements in the pgauss array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_gauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y) +{ + int i, j; + double dhelp, inv_two_sqrt_two_log2, sigma; + double fwhm, centroid, height; + + if (test_params(len_pgauss, 3, "sum_gauss", "height, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pgauss/3; i++) { + height = pgauss[3*i]; + centroid = pgauss[3*i+1]; + fwhm = pgauss[3*i+2]; + + sigma = fwhm * inv_two_sqrt_two_log2; + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid) / sigma; + if (dhelp <= 20) { + y[j] += height * exp (-0.5 * dhelp * dhelp); + } + } + } + return(0); +} + +/* sum_agauss + Sum of gaussian functions defined by (area, centroid, fwhm) + + *area* is the area underneath the peak + *centroid* is the peak x-coordinate + *fwhm* is the full-width at half maximum + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pgauss: Array of gaussian parameters: + (area1, centroid1, fwhm1, area2, centroid2, fwhm2,...) + - len_pgauss: Number of elements in the pgauss array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_agauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y) +{ + int i, j; + double dhelp, height, sqrt2PI, sigma, inv_two_sqrt_two_log2; + double fwhm, centroid, area; + + if (test_params(len_pgauss, 3, "sum_agauss", "area, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + sqrt2PI = sqrt(2.0*M_PI); + + for (i=0; i<len_pgauss/3; i++) { + area = pgauss[3*i]; + centroid = pgauss[3*i+1]; + fwhm = pgauss[3*i+2]; + + sigma = fwhm * inv_two_sqrt_two_log2; + height = area / (sigma * sqrt2PI); + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid)/sigma; + if (dhelp <= 35) { + y[j] += height * exp (-0.5 * dhelp * dhelp); + } + } + } + return(0); +} + + +/* sum_fastagauss + Sum of gaussian functions defined by (area, centroid, fwhm). + This implementation uses a lookup table of precalculated exp values + and a limited development (exp(-x) = 1 - x for small values of x) + + *area* is the area underneath the peak + *centroid* is the peak x-coordinate + *fwhm* is the full-width at half maximum + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pgauss: Array of gaussian parameters: + (area1, centroid1, fwhm1, area2, centroid2, fwhm2,...) + - len_pgauss: Number of elements in the pgauss array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ + +int sum_fastagauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y) +{ + int i, j, expindex; + double dhelp, height, sqrt2PI, sigma, inv_two_sqrt_two_log2; + double fwhm, centroid, area; + static double EXP[5000]; + + if (test_params(len_pgauss, 3, "sum_fastagauss", "area, centroid, fwhm")) { + return(1); + } + + if (EXP[0] < 1){ + for (i=0; i<5000; i++){ + EXP[i] = exp(-0.01 * i); + } + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + sqrt2PI = sqrt(2.0*M_PI); + + for (i=0; i<len_pgauss/3; i++) { + area = pgauss[3*i]; + centroid = pgauss[3*i+1]; + fwhm = pgauss[3*i+2]; + + sigma = fwhm * inv_two_sqrt_two_log2; + height = area / (sigma * sqrt2PI); + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid)/sigma; + if (dhelp <= 15){ + dhelp = 0.5 * dhelp * dhelp; + if (dhelp < 50){ + expindex = (int) (dhelp * 100); + y[j] += height * EXP[expindex] * (1.0 - (dhelp - 0.01 * expindex)); + } + else if (dhelp < 100) { + expindex = (int) (dhelp * 10); + y[j] += height * pow(EXP[expindex] * (1.0 - (dhelp - 0.1 * expindex)), 10); + } + else if (dhelp < 1000){ + expindex = (int) (dhelp); + y[j] += height * pow(EXP[expindex] * (1.0 - (dhelp - expindex)), 20); + } + } + } + } + return(0); +} + +/* sum_splitgauss + Sum of split gaussian functions, defined by (height, centroid, fwhm1, fwhm2) + + *height* is the peak amplitude + *centroid* is the peak x-coordinate + *fwhm1* is the full-width at half maximum of the left half of the curve (x < centroid) + *fwhm1* is the full-width at half maximum of the right half of the curve (x > centroid) + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pgauss: Array of gaussian parameters: + (height1, centroid1, fwhm11, fwhm21, height2, centroid2, fwhm12, fwhm22,...) + - len_pgauss: Number of elements in the pgauss array. Must be + a multiple of 4. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_splitgauss(double* x, int len_x, double* pgauss, int len_pgauss, double* y) +{ + int i, j; + double dhelp, inv_two_sqrt_two_log2, sigma1, sigma2; + double fwhm1, fwhm2, centroid, height; + + if (test_params(len_pgauss, 4, "sum_splitgauss", "height, centroid, fwhm1, fwhm2")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pgauss/4; i++) { + height = pgauss[4*i]; + centroid = pgauss[4*i+1]; + fwhm1 = pgauss[4*i+2]; + fwhm2 = pgauss[4*i+3]; + + sigma1 = fwhm1 * inv_two_sqrt_two_log2; + sigma2 = fwhm2 * inv_two_sqrt_two_log2; + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid); + if (dhelp > 0) { + /* Use fwhm2 when x > centroid */ + dhelp = dhelp / sigma2; + } + else { + /* Use fwhm1 when x < centroid */ + dhelp = dhelp / sigma1; + } + + if (dhelp <= 20) { + y[j] += height * exp (-0.5 * dhelp * dhelp); + } + } + } + return(0); +} + +/* sum_apvoigt + Sum of pseudo-Voigt functions, defined by (area, centroid, fwhm, eta). + + The pseudo-Voigt profile PV(x) is an approximation of the Voigt profile + using a linear combination of a Gaussian curve G(x) and a Lorentzian curve + L(x) instead of their convolution. + + *area* is the area underneath both G(x) and L(x) + *centroid* is the peak x-coordinate for both functions + *fwhm* is the full-width at half maximum of both functions + *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pvoigt: Array of Voigt function parameters: + (area1, centroid1, fwhm1, eta1, area2, centroid2, fwhm2, eta2,...) + - len_voigt: Number of elements in the pvoigt array. Must be + a multiple of 4. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_apvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y) +{ + int i, j; + double dhelp, inv_two_sqrt_two_log2, sqrt2PI, sigma, height; + double area, centroid, fwhm, eta; + + if (test_params(len_pvoigt, 4, "sum_apvoigt", "area, centroid, fwhm, eta")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + sqrt2PI = sqrt(2.0*M_PI); + + + for (i=0; i<len_pvoigt/4; i++) { + area = pvoigt[4*i]; + centroid = pvoigt[4*i+1]; + fwhm = pvoigt[4*i+2]; + eta = pvoigt[4*i+3]; + + sigma = fwhm * inv_two_sqrt_two_log2; + height = area / (sigma * sqrt2PI); + + for (j=0; j<len_x; j++) { + /* Lorentzian term */ + dhelp = (x[j] - centroid) / (0.5 * fwhm); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += eta * (area / (0.5 * M_PI * fwhm * dhelp)); + + /* Gaussian term */ + dhelp = (x[j] - centroid) / sigma; + if (dhelp <= 35) { + y[j] += (1.0 - eta) * height * exp (-0.5 * dhelp * dhelp); + } + } + } + return(0); +} + +/* sum_pvoigt + Sum of pseudo-Voigt functions, defined by (height, centroid, fwhm, eta). + + The pseudo-Voigt profile PV(x) is an approximation of the Voigt profile + using a linear combination of a Gaussian curve G(x) and a Lorentzian curve + L(x) instead of their convolution. + + *height* is the peak amplitude of G(x) and L(x) + *centroid* is the peak x-coordinate for both functions + *fwhm* is the full-width at half maximum of both functions + *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pvoigt: Array of Voigt function parameters: + (height1, centroid1, fwhm1, eta1, height2, centroid2, fwhm2, eta2,...) + - len_voigt: Number of elements in the pvoigt array. Must be + a multiple of 4. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_pvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y) +{ + int i, j; + double dhelp, inv_two_sqrt_two_log2, sigma; + double height, centroid, fwhm, eta; + + if (test_params(len_pvoigt, 4, "sum_pvoigt", "height, centroid, fwhm, eta")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pvoigt/4; i++) { + height = pvoigt[4*i]; + centroid = pvoigt[4*i+1]; + fwhm = pvoigt[4*i+2]; + eta = pvoigt[4*i+3]; + + sigma = fwhm * inv_two_sqrt_two_log2; + + for (j=0; j<len_x; j++) { + /* Lorentzian term */ + dhelp = (x[j] - centroid) / (0.5 * fwhm); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += eta * height / dhelp; + + /* Gaussian term */ + dhelp = (x[j] - centroid) / sigma; + if (dhelp <= 35) { + y[j] += (1.0 - eta) * height * exp (-0.5 * dhelp * dhelp); + } + } + } + return(0); +} + +/* sum_splitpvoigt + Sum of split pseudo-Voigt functions, defined by + (height, centroid, fwhm1, fwhm2, eta). + + The pseudo-Voigt profile PV(x) is an approximation of the Voigt profile + using a linear combination of a Gaussian curve G(x) and a Lorentzian curve + L(x) instead of their convolution. + + *height* is the peak amplitude of G(x) and L(x) + *centroid* is the peak x-coordinate for both functions + *fwhm1* is the full-width at half maximum of both functions for x < centroid + *fwhm2* is the full-width at half maximum of both functions for x > centroid + *eta* is the Lorentz factor: PV(x) = eta * L(x) + (1 - eta) * G(x) + + Parameters: + ----------- + + - x: Independant variable where the gaussians are calculated. + - len_x: Number of elements in the x array. + - pvoigt: Array of Voigt function parameters: + (height1, centroid1, fwhm11, fwhm21, eta1, ...) + - len_voigt: Number of elements in the pvoigt array. Must be + a multiple of 5. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_splitpvoigt(double* x, int len_x, double* pvoigt, int len_pvoigt, double* y) +{ + int i, j; + double dhelp, inv_two_sqrt_two_log2, x_minus_centroid, sigma1, sigma2; + double height, centroid, fwhm1, fwhm2, eta; + + if (test_params(len_pvoigt, 5, "sum_splitpvoigt", "height, centroid, fwhm1, fwhm2, eta")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + inv_two_sqrt_two_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pvoigt/5; i++) { + height = pvoigt[5*i]; + centroid = pvoigt[5*i+1]; + fwhm1 = pvoigt[5*i+2]; + fwhm2 = pvoigt[5*i+3]; + eta = pvoigt[5*i+4]; + + sigma1 = fwhm1 * inv_two_sqrt_two_log2; + sigma2 = fwhm2 * inv_two_sqrt_two_log2; + + for (j=0; j<len_x; j++) { + x_minus_centroid = (x[j] - centroid); + + /* Use fwhm2 when x > centroid */ + if (x_minus_centroid > 0) { + /* Lorentzian term */ + dhelp = x_minus_centroid / (0.5 * fwhm2); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += eta * height / dhelp; + + /* Gaussian term */ + dhelp = x_minus_centroid / sigma2; + if (dhelp <= 35) { + y[j] += (1.0 - eta) * height * exp (-0.5 * dhelp * dhelp); + } + } + /* Use fwhm1 when x < centroid */ + else { + /* Lorentzian term */ + dhelp = x_minus_centroid / (0.5 * fwhm1); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += eta * height / dhelp; + + /* Gaussian term */ + dhelp = x_minus_centroid / sigma1; + if (dhelp <= 35) { + y[j] += (1.0 - eta) * height * exp (-0.5 * dhelp * dhelp); + } + } + } + } + return(0); +} + +/* sum_lorentz + Sum of Lorentz functions, defined by (height, centroid, fwhm). + + *height* is the peak amplitude + *centroid* is the peak's x-coordinate + *fwhm* is the full-width at half maximum + + Parameters: + ----------- + + - x: Independant variable where the Lorentzians are calculated. + - len_x: Number of elements in the x array. + - plorentz: Array of lorentz function parameters: + (height1, centroid1, fwhm1, ...) + - len_lorentz: Number of elements in the plorentz array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_lorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y) +{ + int i, j; + double dhelp; + double height, centroid, fwhm; + + if (test_params(len_plorentz, 3, "sum_lorentz", "height, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + for (i=0; i<len_plorentz/3; i++) { + height = plorentz[3*i]; + centroid = plorentz[3*i+1]; + fwhm = plorentz[3*i+2]; + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid) / (0.5 * fwhm); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += height / dhelp; + } + } + return(0); +} + + +/* sum_alorentz + Sum of Lorentz functions, defined by (area, centroid, fwhm). + + *area* is the area underneath the peak + *centroid* is the peak's x-coordinate + *fwhm* is the full-width at half maximum + + Parameters: + ----------- + + - x: Independant variable where the Lorentzians are calculated. + - len_x: Number of elements in the x array. + - plorentz: Array of lorentz function parameters: + (area1, centroid1, fwhm1, ...) + - len_lorentz: Number of elements in the plorentz array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_alorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y) +{ + int i, j; + double dhelp; + double area, centroid, fwhm; + + if (test_params(len_plorentz, 3, "sum_alorentz", "area, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + for (i=0; i<len_plorentz/3; i++) { + area = plorentz[3*i]; + centroid = plorentz[3*i+1]; + fwhm = plorentz[3*i+2]; + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid) / (0.5 * fwhm); + dhelp = 1.0 + (dhelp * dhelp); + y[j] += area / (0.5 * M_PI * fwhm * dhelp); + } + } + return(0); +} + + +/* sum_splitlorentz + Sum of Lorentz functions, defined by (height, centroid, fwhm1, fwhm2). + + *height* is the peak amplitude + *centroid* is the peak's x-coordinate + *fwhm1* is the full-width at half maximum for x < centroid + *fwhm2* is the full-width at half maximum for x > centroid + + Parameters: + ----------- + + - x: Independant variable where the Lorentzians are calculated. + - len_x: Number of elements in the x array. + - plorentz: Array of lorentz function parameters: + (height1, centroid1, fwhm11, fwhm21 ...) + - len_lorentz: Number of elements in the plorentz array. Must be + a multiple of 4. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_splitlorentz(double* x, int len_x, double* plorentz, int len_plorentz, double* y) +{ + int i, j; + double dhelp; + double height, centroid, fwhm1, fwhm2; + + if (test_params(len_plorentz, 4, "sum_splitlorentz", "height, centroid, fwhm1, fwhm2")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + for (i=0; i<len_plorentz/4; i++) { + height = plorentz[4*i]; + centroid = plorentz[4*i+1]; + fwhm1 = plorentz[4*i+2]; + fwhm2 = plorentz[4*i+3]; + + for (j=0; j<len_x; j++) { + dhelp = (x[j] - centroid); + if (dhelp>0) { + dhelp = dhelp / (0.5 * fwhm2); + } + else { + dhelp = dhelp / (0.5 * fwhm1); + } + dhelp = 1.0 + (dhelp * dhelp); + y[j] += height / dhelp; + } + } + return(0); +} + +/* sum_stepdown + Sum of stepdown functions, defined by (height, centroid, fwhm). + + *height* is the step amplitude + *centroid* is the step's x-coordinate + *fwhm* is the full-width at half maximum of the derivative + + Parameters: + ----------- + + - x: Independant variable where the stepdown functions are calculated. + - len_x: Number of elements in the x array. + - pdstep: Array of downstpe function parameters: + (height1, centroid1, fwhm1, ...) + - len_pdstep: Number of elements in the pdstep array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_stepdown(double* x, int len_x, double* pdstep, int len_pdstep, double* y) +{ + int i, j; + double dhelp, sqrt2_inv_2_sqrt_two_log2 ; + double height, centroid, fwhm; + + if (test_params(len_pdstep, 3, "sum_stepdown", "height, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + sqrt2_inv_2_sqrt_two_log2 = sqrt(2.0) / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pdstep/3; i++) { + height = pdstep[3*i]; + centroid = pdstep[3*i+1]; + fwhm = pdstep[3*i+2]; + + for (j=0; j<len_x; j++) { + dhelp = fwhm * sqrt2_inv_2_sqrt_two_log2; + dhelp = (x[j] - centroid) / dhelp; + y[j] += height * 0.5 * erfc(dhelp); + } + } + return(0); +} + +/* sum_stepup + Sum of stepup functions, defined by (height, centroid, fwhm). + + *height* is the step amplitude + *centroid* is the step's x-coordinate + *fwhm* is the full-width at half maximum of the derivative + + Parameters: + ----------- + + - x: Independant variable where the stepup functions are calculated. + - len_x: Number of elements in the x array. + - pustep: Array of stepdown function parameters: + (height1, centroid1, fwhm1, ...) + - len_pustep: Number of elements in the pustep array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_stepup(double* x, int len_x, double* pustep, int len_pustep, double* y) +{ + int i, j; + double dhelp, sqrt2_inv_2_sqrt_two_log2 ; + double height, centroid, fwhm; + + if (test_params(len_pustep, 3, "sum_stepup", "height, centroid, fwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + sqrt2_inv_2_sqrt_two_log2 = sqrt(2.0) / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pustep/3; i++) { + height = pustep[3*i]; + centroid = pustep[3*i+1]; + fwhm = pustep[3*i+2]; + + for (j=0; j<len_x; j++) { + dhelp = fwhm * sqrt2_inv_2_sqrt_two_log2; + dhelp = (x[j] - centroid) / dhelp; + y[j] += height * 0.5 * (1.0 + erf(dhelp)); + } + } + return(0); +} + + +/* sum_slit + Sum of slit functions, defined by (height, position, fwhm, beamfwhm). + + *height* is the slit height + *position* is the slit's center x-coordinate + *fwhm* is the full-width at half maximum of the slit + *beamfwhm* is the full-width at half maximum of derivative's peaks + + Parameters: + ----------- + + - x: Independant variable where the slit functions are calculated. + - len_x: Number of elements in the x array. + - pslit: Array of slit function parameters: + (height1, centroid1, fwhm1, beamfwhm1 ...) + - len_pslit: Number of elements in the pslit array. Must be + a multiple of 3. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + + Adapted from PyMca module SpecFitFuns +*/ +int sum_slit(double* x, int len_x, double* pslit, int len_pslit, double* y) +{ + int i, j; + double dhelp, dhelp1, dhelp2, sqrt2_inv_2_sqrt_two_log2, centroid1, centroid2; + double height, position, fwhm, beamfwhm; + + if (test_params(len_pslit, 4, "sum_slit", "height, centroid, fwhm, beamfwhm")) { + return(1); + } + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + sqrt2_inv_2_sqrt_two_log2 = sqrt(2.0) / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_pslit/4; i++) { + height = pslit[4*i]; + position = pslit[4*i+1]; + fwhm = pslit[4*i+2]; + beamfwhm = pslit[4*i+3]; + + centroid1 = position - 0.5 * fwhm; + centroid2 = position + 0.5 * fwhm; + + for (j=0; j<len_x; j++) { + dhelp = beamfwhm * sqrt2_inv_2_sqrt_two_log2; + dhelp1 = (x[j] - centroid1) / dhelp; + dhelp2 = (x[j] - centroid2) / dhelp; + y[j] += height * 0.25 * (1.0 + erf(dhelp1)) * erfc(dhelp2); + } + } + return(0); +} + + +/* sum_ahypermet + Sum of hypermet functions, defined by + (area, position, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r). + + - *area* is the area underneath the gaussian peak + - *position* is the center of the various peaks and the position of + the step down + - *fwhm* is the full-width at half maximum of the terms + - *st_area_r* is factor between the gaussian area and the area of the + short tail term + - *st_slope_r* is a parameter related to the slope of the short tail + in the low ``x`` values (the lower, the steeper) + - *lt_area_r* is factor between the gaussian area and the area of the + long tail term + - *lt_slope_r* is a parameter related to the slope of the long tail + in the low ``x`` values (the lower, the steeper) + - *step_height_r* is the factor between the height of the step down + and the gaussian height + + Parameters: + ----------- + + - x: Independant variable where the functions are calculated. + - len_x: Number of elements in the x array. + - phypermet: Array of hypermet function parameters: + *(area1, position1, fwhm1, st_area_r1, st_slope_r1, lt_area_r1, + lt_slope_r1, step_height_r1, ...)* + - len_phypermet: Number of elements in the phypermet array. Must be + a multiple of 8. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + - tail_flags: sum of binary flags to activate the various terms of the + function: + + - 1 (b0001): Gaussian term + - 2 (b0010): st term + - 4 (b0100): lt term + - 8 (b1000): step term + + E.g., to activate all termsof the hypermet, use ``tail_flags = 1 + 2 + 4 + 8 = 15`` + + Adapted from PyMca module SpecFitFuns +*/ +int sum_ahypermet(double* x, int len_x, double* phypermet, int len_phypermet, double* y, int tail_flags) +{ + int i, j; + int g_term_flag, st_term_flag, lt_term_flag, step_term_flag; + double c1, c2, sigma, height, sigma_sqrt2, sqrt2PI, inv_2_sqrt_2_log2, x_minus_position, epsilon; + double area, position, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r; + + if (test_params(len_phypermet, 8, "sum_hypermet", + "height, centroid, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r")) { + return(1); + } + + g_term_flag = tail_flags & 1; + st_term_flag = (tail_flags>>1) & 1; + lt_term_flag = (tail_flags>>2) & 1; + step_term_flag = (tail_flags>>3) & 1; + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + /* define epsilon to compare floating point values with 0. */ + epsilon = 0.00000000001; + + sqrt2PI= sqrt(2.0 * M_PI); + inv_2_sqrt_2_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_phypermet/8; i++) { + area = phypermet[8*i]; + position = phypermet[8*i+1]; + fwhm = phypermet[8*i+2]; + st_area_r = phypermet[8*i+3]; + st_slope_r = phypermet[8*i+4]; + lt_area_r = phypermet[8*i+5]; + lt_slope_r = phypermet[8*i+6]; + step_height_r = phypermet[8*i+7]; + + sigma = fwhm * inv_2_sqrt_2_log2; + height = area / (sigma * sqrt2PI); + + /* Prevent division by 0 */ + if (sigma == 0) { + printf("fwhm must not be equal to 0"); + return(1); + } + sigma_sqrt2 = sigma * 1.4142135623730950488; + + for (j=0; j<len_x; j++) { + x_minus_position = x[j] - position; + c2 = (0.5 * x_minus_position * x_minus_position) / (sigma * sigma); + /* gaussian term */ + if (g_term_flag) { + y[j] += exp(-c2) * height; + } + + /* st term */ + if (st_term_flag) { + if (fabs(st_slope_r) > epsilon) { + c1 = st_area_r * 0.5 * \ + erfc((x_minus_position/sigma_sqrt2) + 0.5 * sigma_sqrt2 / st_slope_r); + y[j] += ((area * c1) / st_slope_r) * \ + exp(0.5 * (sigma / st_slope_r) * (sigma / st_slope_r) + \ + (x_minus_position / st_slope_r)); + } + } + + /* lt term */ + if (lt_term_flag) { + if (fabs(lt_slope_r) > epsilon) { + c1 = lt_area_r * \ + 0.5 * erfc((x_minus_position/sigma_sqrt2) + 0.5 * sigma_sqrt2 / lt_slope_r); + y[j] += ((area * c1) / lt_slope_r) * \ + exp(0.5 * (sigma / lt_slope_r) * (sigma / lt_slope_r) + \ + (x_minus_position / lt_slope_r)); + } + } + + /* step term flag */ + if (step_term_flag) { + y[j] += step_height_r * (area / (sigma * sqrt2PI)) * \ + 0.5 * erfc(x_minus_position / sigma_sqrt2); + } + } + } + return(0); +} + +/* sum_fastahypermet + + Sum of hypermet functions, defined by + (area, position, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r). + + - *area* is the area underneath the gaussian peak + - *position* is the center of the various peaks and the position of + the step down + - *fwhm* is the full-width at half maximum of the terms + - *st_area_r* is factor between the gaussian area and the area of the + short tail term + - *st_slope_r* is a parameter related to the slope of the short tail + in the low ``x`` values (the lower, the steeper) + - *lt_area_r* is factor between the gaussian area and the area of the + long tail term + - *lt_slope_r* is a parameter related to the slope of the long tail + in the low ``x`` values (the lower, the steeper) + - *step_height_r* is the factor between the height of the step down + and the gaussian height + + Parameters: + ----------- + + - x: Independant variable where the functions are calculated. + - len_x: Number of elements in the x array. + - phypermet: Array of hypermet function parameters: + *(area1, position1, fwhm1, st_area_r1, st_slope_r1, lt_area_r1, + lt_slope_r1, step_height_r1, ...)* + - len_phypermet: Number of elements in the phypermet array. Must be + a multiple of 8. + - y: Output array. Must have memory allocated for the same number + of elements as x (len_x). + - tail_flags: sum of binary flags to activate the various terms of the + function: + + - 1 (b0001): Gaussian term + - 2 (b0010): st term + - 4 (b0100): lt term + - 8 (b1000): step term + + E.g., to activate all termsof the hypermet, use ``tail_flags = 1 + 2 + 4 + 8 = 15`` + + Adapted from PyMca module SpecFitFuns +*/ +int sum_fastahypermet(double* x, int len_x, double* phypermet, int len_phypermet, double* y, int tail_flags) +{ + int i, j; + int g_term_flag, st_term_flag, lt_term_flag, step_term_flag; + double c1, c2, sigma, height, sigma_sqrt2, sqrt2PI, inv_2_sqrt_2_log2, x_minus_position, epsilon; + double area, position, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r; + + if (test_params(len_phypermet, 8, "sum_hypermet", + "height, centroid, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r")) { + return(1); + } + + g_term_flag = tail_flags & 1; + st_term_flag = (tail_flags>>1) & 1; + lt_term_flag = (tail_flags>>2) & 1; + step_term_flag = (tail_flags>>3) & 1; + + /* Initialize output array */ + for (j=0; j<len_x; j++) { + y[j] = 0.; + } + + /* define epsilon to compare floating point values with 0. */ + epsilon = 0.00000000001; + + sqrt2PI= sqrt(2.0 * M_PI); + inv_2_sqrt_2_log2 = 1.0 / (2.0 * sqrt(2.0 * LOG2)); + + for (i=0; i<len_phypermet/8; i++) { + area = phypermet[8*i]; + position = phypermet[8*i+1]; + fwhm = phypermet[8*i+2]; + st_area_r = phypermet[8*i+3]; + st_slope_r = phypermet[8*i+4]; + lt_area_r = phypermet[8*i+5]; + lt_slope_r = phypermet[8*i+6]; + step_height_r = phypermet[8*i+7]; + + sigma = fwhm * inv_2_sqrt_2_log2; + height = area / (sigma * sqrt2PI); + + /* Prevent division by 0 */ + if (sigma == 0) { + printf("fwhm must not be equal to 0"); + return(1); + } + sigma_sqrt2 = sigma * 1.4142135623730950488; + + for (j=0; j<len_x; j++) { + x_minus_position = x[j] - position; + c2 = (0.5 * x_minus_position * x_minus_position) / (sigma * sigma); + /* gaussian term */ + if (g_term_flag && c2 < 100) { + y[j] += fastexp(-c2) * height; + } + + /* st term */ + if (st_term_flag && (fabs(st_slope_r) > epsilon) && (x_minus_position / st_slope_r) <= 612) { + c1 = st_area_r * 0.5 * \ + erfc((x_minus_position/sigma_sqrt2) + 0.5 * sigma_sqrt2 / st_slope_r); + y[j] += ((area * c1) / st_slope_r) * \ + fastexp(0.5 * (sigma / st_slope_r) * (sigma / st_slope_r) +\ + (x_minus_position / st_slope_r)); + } + + /* lt term */ + if (lt_term_flag && (fabs(lt_slope_r) > epsilon) && (x_minus_position / lt_slope_r) <= 612) { + c1 = lt_area_r * \ + 0.5 * erfc((x_minus_position/sigma_sqrt2) + 0.5 * sigma_sqrt2 / lt_slope_r); + y[j] += ((area * c1) / lt_slope_r) * \ + fastexp(0.5 * (sigma / lt_slope_r) * (sigma / lt_slope_r) +\ + (x_minus_position / lt_slope_r)); + + } + + /* step term flag */ + if (step_term_flag) { + y[j] += step_height_r * (area / (sigma * sqrt2PI)) *\ + 0.5 * erfc(x_minus_position / sigma_sqrt2); + } + } + } + return(0); +} + +void pileup(double* x, long len_x, double* ret, int input2, double zero, double gain) +{ + //int input2=0; + //double zero=0.0; + //double gain=1.0; + + int i, j, k; + double *px, *pret, *pall; + + /* the pointer to the starting position of par data */ + px = x; + pret = ret; + + *pret = 0; + k = (int )(zero/gain); + for (i=input2; i<len_x; i++){ + pall = x; + if ((i+k) >= 0) + { + pret = (double *) ret+(i+k); + for (j=0; j<len_x-i-k ;j++){ + *pret += *px * (*pall); + pall++; + pret++; + } + } + px++; + } +} diff --git a/src/silx/math/fit/functions_wrapper.pxd b/src/silx/math/fit/functions_wrapper.pxd new file mode 100644 index 0000000..780116c --- /dev/null +++ b/src/silx/math/fit/functions_wrapper.pxd @@ -0,0 +1,170 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "14/06/2016" + +cimport cython + +cdef extern from "functions.h": + int erfc_array(double* x, + int len_x, + double* y) + + int erf_array(double* x, + int len_x, + double* y); + + void snip1d(double *data, + int size, + int width) + + void snip2d(double *data, + int nrows, + int ncolumns, + int width) + + void snip3d(double *data, + int nx, + int ny, + int nz, + int width) + + int strip(double* input, + long len_input, + double c, + long niter, + int deltai, + long* anchors, + long len_anchors, + double* output) + + int sum_gauss(double* x, + int len_x, + double* pgauss, + int len_pgauss, + double* y) + + int sum_agauss(double* x, + int len_x, + double* pgauss, + int len_pgauss, + double* y) + + int sum_fastagauss(double* x, + int len_x, + double* pgauss, + int len_pgauss, + double* y) + + int sum_splitgauss(double* x, + int len_x, + double* pgauss, + int len_pgauss, + double* y) + + int sum_apvoigt(double* x, + int len_x, + double* pvoigt, + int len_pvoigt, + double* y) + + int sum_pvoigt(double* x, + int len_x, + double* pvoigt, + int len_pvoigt, + double* y) + + int sum_splitpvoigt(double* x, + int len_x, + double* pvoigt, + int len_pvoigt, + double* y) + + int sum_lorentz(double* x, + int len_x, + double* plorentz, + int len_plorentz, + double* y) + + int sum_alorentz(double* x, + int len_x, + double* plorentz, + int len_plorentz, + double* y) + + int sum_splitlorentz(double* x, + int len_x, + double* plorentz, + int len_plorentz, + double* y) + + int sum_stepdown(double* x, + int len_x, + double* pdstep, + int len_pdstep, + double* y) + + int sum_stepup(double* x, + int len_x, + double* pustep, + int len_pustep, + double* y) + + int sum_slit(double* x, + int len_x, + double* pslit, + int len_pslit, + double* y) + + int sum_ahypermet(double* x, + int len_x, + double* phypermet, + int len_phypermet, + double* y, + int tail_flags) + + int sum_fastahypermet(double* x, + int len_x, + double* phypermet, + int len_phypermet, + double* y, + int tail_flags) + + long seek(long begin_index, + long end_index, + long nsamples, + double fwhm, + double sensitivity, + double debug_info, + long max_npeaks, + double * data, + double * peaks, + double * relevances) + + int SavitskyGolay(double* input, + long len_input, + int npoints, + double* output) diff --git a/src/silx/math/fit/leastsq.py b/src/silx/math/fit/leastsq.py new file mode 100644 index 0000000..3df1a35 --- /dev/null +++ b/src/silx/math/fit/leastsq.py @@ -0,0 +1,901 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +This module implements a Levenberg-Marquardt algorithm with constraints on the +fitted parameters without introducing any other dependendency than numpy. + +If scipy dependency is not an issue, and no constraints are applied to the fitting +parameters, there is no real gain compared to the use of scipy.optimize.curve_fit +other than a more conservative calculation of uncertainties on fitted parameters. + +This module is a refactored version of PyMca Gefit.py module. +""" +__authors__ = ["V.A. Sole"] +__license__ = "MIT" +__date__ = "15/05/2017" +__copyright__ = "European Synchrotron Radiation Facility, Grenoble, France" + +import numpy +from numpy.linalg import inv +from numpy.linalg.linalg import LinAlgError +import time +import logging +import copy + +_logger = logging.getLogger(__name__) + +# codes understood by the routine +CFREE = 0 +CPOSITIVE = 1 +CQUOTED = 2 +CFIXED = 3 +CFACTOR = 4 +CDELTA = 5 +CSUM = 6 +CIGNORED = 7 + +def leastsq(model, xdata, ydata, p0, sigma=None, + constraints=None, model_deriv=None, epsfcn=None, + deltachi=None, full_output=None, + check_finite=True, + left_derivative=False, + max_iter=100): + """ + Use non-linear least squares Levenberg-Marquardt algorithm to fit a function, f, to + data with optional constraints on the fitted parameters. + + Assumes ``ydata = f(xdata, *params) + eps`` + + :param model: callable + The model function, f(x, ...). It must take the independent + variable as the first argument and the parameters to fit as + separate remaining arguments. + The returned value is a one dimensional array of floats. + + :param xdata: An M-length sequence. + The independent variable where the data is measured. + + :param ydata: An M-length sequence + The dependent data --- nominally f(xdata, ...) + + :param p0: N-length sequence + Initial guess for the parameters. + + :param sigma: None or M-length sequence, optional + If not None, the uncertainties in the ydata array. These are used as + weights in the least-squares problem + i.e. minimising ``np.sum( ((f(xdata, *popt) - ydata) / sigma)**2 )`` + If None, the uncertainties are assumed to be 1 + + :param constraints: + If provided, it is a 2D sequence of dimension (n_parameters, 3) where, + for each parameter denoted by the index i, the meaning is + + - constraints[i][0] + + - 0 - Free (CFREE) + - 1 - Positive (CPOSITIVE) + - 2 - Quoted (CQUOTED) + - 3 - Fixed (CFIXED) + - 4 - Factor (CFACTOR) + - 5 - Delta (CDELTA) + - 6 - Sum (CSUM) + + + - constraints[i][1] + + - Ignored if constraints[i][0] is 0, 1, 3 + - Min value of the parameter if constraints[i][0] is CQUOTED + - Index of fitted parameter to which it is related + + - constraints[i][2] + + - Ignored if constraints[i][0] is 0, 1, 3 + - Max value of the parameter if constraints[i][0] is CQUOTED + - Factor to apply to related parameter with index constraints[i][1] + - Difference with parameter with index constraints[i][1] + - Sum obtained when adding parameter with index constraints[i][1] + :type constraints: *optional*, None or 2D sequence + + :param model_deriv: + None (default) or function providing the derivatives of the fitting function respect to the fitted parameters. + It will be called as model_deriv(xdata, parameters, index) where parameters is a sequence with the current + values of the fitting parameters, index is the fitting parameter index for which the the derivative has + to be provided in the supplied array of xdata points. + :type model_deriv: *optional*, None or callable + + + :param epsfcn: float + A variable used in determining a suitable parameter variation when + calculating the numerical derivatives (for model_deriv=None). + Normally the actual step length will be sqrt(epsfcn)*x + Original Gefit module was using epsfcn 1.0e-5 while default value + is now numpy.finfo(numpy.float64).eps as in scipy + :type epsfcn: *optional*, float + + :param deltachi: float + A variable used to control the minimum change in chisq to consider the + fitting process not worth to be continued. Default is 0.1 %. + :type deltachi: *optional*, float + + :param full_output: bool, optional + non-zero to return all optional outputs. The default is None what will give a warning in case + of a constrained fit without having set this kweyword. + + :param check_finite: bool, optional + If True, check that the input arrays do not contain nans of infs, + and raise a ValueError if they do. Setting this parameter to + False will ignore input arrays values containing nans. + Default is True. + + :param left_derivative: + This parameter only has an influence if no derivative function + is provided. When True the left and right derivatives of the + model will be calculated for each fitted parameters thus leading to + the double number of function evaluations. Default is False. + Original Gefit module was always using left_derivative as True. + :type left_derivative: *optional*, bool + + :param max_iter: Maximum number of iterations (default is 100) + + :return: Returns a tuple of length 2 (or 3 if full_ouput is True) with the content: + + ``popt``: array + Optimal values for the parameters so that the sum of the squared error + of ``f(xdata, *popt) - ydata`` is minimized + ``pcov``: 2d array + If no constraints are applied, this array contains the estimated covariance + of popt. The diagonal provides the variance of the parameter estimate. + To compute one standard deviation errors use ``perr = np.sqrt(np.diag(pcov))``. + If constraints are applied, this array does not contain the estimated covariance of + the parameters actually used during the fitting process but the uncertainties after + recalculating the covariance if all the parameters were free. + To get the actual uncertainties following error propagation of the actually fitted + parameters one should set full_output to True and access the uncertainties key. + ``infodict``: dict + a dictionary of optional outputs with the keys: + + ``uncertainties`` + The actual uncertainty on the optimized parameters. + ``nfev`` + The number of function calls + ``fvec`` + The function evaluated at the output + ``niter`` + The number of iterations performed + ``chisq`` + The chi square ``np.sum( ((f(xdata, *popt) - ydata) / sigma)**2 )`` + ``reduced_chisq`` + The chi square ``np.sum( ((f(xdata, *popt) - ydata) / sigma)**2 )`` divided + by the number of degrees of freedom ``(M - number_of_free_parameters)`` + """ + function_call_counter = 0 + if numpy.isscalar(p0): + p0 = [p0] + parameters = numpy.array(p0, dtype=numpy.float64, copy=False) + if deltachi is None: + deltachi = 0.001 + + # NaNs can not be handled + if check_finite: + xdata = numpy.asarray_chkfinite(xdata) + ydata = numpy.asarray_chkfinite(ydata) + if sigma is not None: + sigma = numpy.asarray_chkfinite(sigma) + else: + sigma = numpy.ones((ydata.shape), dtype=numpy.float64) + ydata.shape = -1 + sigma.shape = -1 + else: + ydata = numpy.asarray(ydata) + xdata = numpy.asarray(xdata) + ydata.shape = -1 + if sigma is not None: + sigma = numpy.asarray(sigma) + else: + sigma = numpy.ones((ydata.shape), dtype=numpy.float64) + sigma.shape = -1 + # get rid of NaN in input data + idx = numpy.isfinite(ydata) + if False in idx: + # xdata must have a shape able to be understood by the user function + # in principle, one should not need to change it, however, if there are + # points to be excluded, one has to be able to exclude them. + # We can only hope that the sequence is properly arranged + if xdata.size == ydata.size: + if len(xdata.shape) != 1: + msg = "Need to reshape input xdata." + _logger.warning(msg) + xdata.shape = -1 + else: + raise ValueError("Cannot reshape xdata to deal with NaN in ydata") + ydata = ydata[idx] + xdata = xdata[idx] + sigma = sigma[idx] + idx = numpy.isfinite(sigma) + if False in idx: + # xdata must have a shape able to be understood by the user function + # in principle, one should not need to change it, however, if there are + # points to be excluded, one has to be able to exclude them. + # We can only hope that the sequence is properly arranged + ydata = ydata[idx] + xdata = xdata[idx] + sigma = sigma[idx] + idx = numpy.isfinite(xdata) + filter_xdata = False + if False in idx: + # What to do? + try: + # Let's see if the function is able to deal with non-finite data + msg = "Checking if function can deal with non-finite data" + _logger.debug(msg) + evaluation = model(xdata, *parameters) + function_call_counter += 1 + if evaluation.shape != ydata.shape: + if evaluation.size == ydata.size: + msg = "Supplied function does not return a proper array of floats." + msg += "\nFunction should be rewritten to return a 1D array of floats." + msg += "\nTrying to reshape output." + _logger.warning(msg) + evaluation.shape = ydata.shape + if False in numpy.isfinite(evaluation): + msg = "Supplied function unable to handle non-finite x data" + msg += "\nAttempting to filter out those x data values." + _logger.warning(msg) + filter_xdata = True + else: + filter_xdata = False + evaluation = None + except: + # function cannot handle input data + filter_xdata = True + if filter_xdata: + if xdata.size != ydata.size: + raise ValueError("xdata contains non-finite data that cannot be filtered") + else: + # we leave the xdata as they where + old_shape = xdata.shape + xdata.shape = ydata.shape + idx0 = numpy.isfinite(xdata) + xdata.shape = old_shape + ydata = ydata[idx0] + xdata = xdata[idx] + sigma = sigma[idx0] + weight = 1.0 / (sigma + numpy.equal(sigma, 0)) + weight0 = weight * weight + + nparameters = len(parameters) + + if epsfcn is None: + epsfcn = numpy.finfo(numpy.float64).eps + else: + epsfcn = max(epsfcn, numpy.finfo(numpy.float64).eps) + + # check if constraints have been passed as text + constrained_fit = False + if constraints is not None: + # make sure we work with a list of lists + input_constraints = constraints + tmp_constraints = [None] * len(input_constraints) + for i in range(nparameters): + tmp_constraints[i] = list(input_constraints[i]) + constraints = tmp_constraints + for i in range(nparameters): + if hasattr(constraints[i][0], "upper"): + txt = constraints[i][0].upper() + if txt == "FREE": + constraints[i][0] = CFREE + elif txt == "POSITIVE": + constraints[i][0] = CPOSITIVE + elif txt == "QUOTED": + constraints[i][0] = CQUOTED + elif txt == "FIXED": + constraints[i][0] = CFIXED + elif txt == "FACTOR": + constraints[i][0] = CFACTOR + constraints[i][1] = int(constraints[i][1]) + elif txt == "DELTA": + constraints[i][0] = CDELTA + constraints[i][1] = int(constraints[i][1]) + elif txt == "SUM": + constraints[i][0] = CSUM + constraints[i][1] = int(constraints[i][1]) + elif txt in ["IGNORED", "IGNORE"]: + constraints[i][0] = CIGNORED + else: + #I should raise an exception + raise ValueError("Unknown constraint %s" % constraints[i][0]) + if constraints[i][0] > 0: + constrained_fit = True + if constrained_fit: + if full_output is None: + _logger.info("Recommended to set full_output to True when using constraints") + + # Levenberg-Marquardt algorithm + fittedpar = parameters.__copy__() + flambda = 0.001 + iiter = max_iter + #niter = 0 + last_evaluation=None + x = xdata + y = ydata + chisq0 = -1 + iteration_counter = 0 + while (iiter > 0): + weight = weight0 + """ + I cannot evaluate the initial chisq here because I do not know + if some parameters are to be ignored, otherways I could do it as follows: + if last_evaluation is None: + yfit = model(x, *fittedpar) + last_evaluation = yfit + chisq0 = (weight * pow(y-yfit, 2)).sum() + and chisq would not need to be recalculated. + Passing the last_evaluation assumes that there are no parameters being + ignored or not between calls. + """ + iteration_counter += 1 + chisq0, alpha0, beta, internal_output = chisq_alpha_beta( + model, fittedpar, + x, y, weight, constraints=constraints, + model_deriv=model_deriv, + epsfcn=epsfcn, + left_derivative=left_derivative, + last_evaluation=last_evaluation, + full_output=True) + n_free = internal_output["n_free"] + free_index = internal_output["free_index"] + noigno = internal_output["noigno"] + fitparam = internal_output["fitparam"] + function_calls = internal_output["function_calls"] + function_call_counter += function_calls + #print("chisq0 = ", chisq0, n_free, fittedpar) + #raise + nr, nc = alpha0.shape + flag = 0 + #lastdeltachi = chisq0 + while flag == 0: + alpha = alpha0 * (1.0 + flambda * numpy.identity(nr)) + deltapar = numpy.dot(beta, inv(alpha)) + if constraints is None: + newpar = fitparam + deltapar [0] + else: + newpar = parameters.__copy__() + pwork = numpy.zeros(deltapar.shape, numpy.float64) + for i in range(n_free): + if constraints is None: + pwork [0] [i] = fitparam [i] + deltapar [0] [i] + elif constraints [free_index[i]][0] == CFREE: + pwork [0] [i] = fitparam [i] + deltapar [0] [i] + elif constraints [free_index[i]][0] == CPOSITIVE: + #abs method + pwork [0] [i] = fitparam [i] + deltapar [0] [i] + #square method + #pwork [0] [i] = (numpy.sqrt(fitparam [i]) + deltapar [0] [i]) * \ + # (numpy.sqrt(fitparam [i]) + deltapar [0] [i]) + elif constraints[free_index[i]][0] == CQUOTED: + pmax = max(constraints[free_index[i]][1], + constraints[free_index[i]][2]) + pmin = min(constraints[free_index[i]][1], + constraints[free_index[i]][2]) + A = 0.5 * (pmax + pmin) + B = 0.5 * (pmax - pmin) + if B != 0: + pwork [0] [i] = A + \ + B * numpy.sin(numpy.arcsin((fitparam[i] - A)/B)+ \ + deltapar [0] [i]) + else: + txt = "Error processing constrained fit\n" + txt += "Parameter limits are %g and %g\n" % (pmin, pmax) + txt += "A = %g B = %g" % (A, B) + raise ValueError("Invalid parameter limits") + newpar[free_index[i]] = pwork [0] [i] + newpar = numpy.array(_get_parameters(newpar, constraints)) + workpar = numpy.take(newpar, noigno) + yfit = model(x, *workpar) + if last_evaluation is None: + if len(yfit.shape) > 1: + msg = "Supplied function does not return a 1D array of floats." + msg += "\nFunction should be rewritten." + msg += "\nTrying to reshape output." + _logger.warning(msg) + yfit.shape = -1 + function_call_counter += 1 + chisq = (weight * pow(y-yfit, 2)).sum() + absdeltachi = chisq0 - chisq + if absdeltachi < 0: + flambda *= 10.0 + if flambda > 1000: + flag = 1 + iiter = 0 + else: + flag = 1 + fittedpar = newpar.__copy__() + lastdeltachi = 100 * (absdeltachi / (chisq + (chisq == 0))) + if iteration_counter < 2: + # ignore any limit, the fit *has* to be improved + pass + elif (lastdeltachi) < deltachi: + iiter = 0 + elif absdeltachi < numpy.sqrt(epsfcn): + iiter = 0 + _logger.info("Iteration finished due to too small absolute chi decrement") + chisq0 = chisq + flambda = flambda / 10.0 + last_evaluation = yfit + iiter = iiter - 1 + # this is the covariance matrix of the actually fitted parameters + cov0 = inv(alpha0) + if constraints is None: + cov = cov0 + else: + # yet another call needed with all the parameters being free except those + # that are FIXED and that will be assigned a 100 % uncertainty. + new_constraints = copy.deepcopy(constraints) + flag_special = [0] * len(fittedpar) + for idx, constraint in enumerate(constraints): + if constraints[idx][0] in [CFIXED, CIGNORED]: + flag_special[idx] = constraints[idx][0] + else: + new_constraints[idx][0] = CFREE + new_constraints[idx][1] = 0 + new_constraints[idx][2] = 0 + chisq, alpha, beta, internal_output = chisq_alpha_beta( + model, fittedpar, + x, y, weight, constraints=new_constraints, + model_deriv=model_deriv, + epsfcn=epsfcn, + left_derivative=left_derivative, + last_evaluation=last_evaluation, + full_output=True) + # obtained chisq should be identical to chisq0 + try: + cov = inv(alpha) + except LinAlgError: + _logger.critical("Error calculating covariance matrix after successful fit") + cov = None + if cov is not None: + for idx, value in enumerate(flag_special): + if value in [CFIXED, CIGNORED]: + cov = numpy.insert(numpy.insert(cov, idx, 0, axis=1), idx, 0, axis=0) + cov[idx, idx] = fittedpar[idx] * fittedpar[idx] + + if not full_output: + return fittedpar, cov + else: + sigma0 = numpy.sqrt(abs(numpy.diag(cov0))) + sigmapar = _get_sigma_parameters(fittedpar, sigma0, constraints) + ddict = {} + ddict["chisq"] = chisq0 + ddict["reduced_chisq"] = chisq0 / (len(yfit)-n_free) + ddict["covariance"] = cov0 + ddict["uncertainties"] = sigmapar + ddict["fvec"] = last_evaluation + ddict["nfev"] = function_call_counter + ddict["niter"] = iteration_counter + return fittedpar, cov, ddict #, chisq/(len(yfit)-len(sigma0)), sigmapar,niter,lastdeltachi + +def chisq_alpha_beta(model, parameters, x, y, weight, constraints=None, + model_deriv=None, epsfcn=None, left_derivative=False, + last_evaluation=None, full_output=False): + + """ + Get chi square, the curvature matrix alpha and the matrix beta according to the input parameters. + If all the parameters are unconstrained, the covariance matrix is the inverse of the alpha matrix. + + :param model: callable + The model function, f(x, ...). It must take the independent + variable as the first argument and the parameters to fit as + separate remaining arguments. + The returned value is a one dimensional array of floats. + + :param parameters: N-length sequence + Values of parameters at which function and derivatives are to be calculated. + + :param x: An M-length sequence. + The independent variable where the data is measured. + + :param y: An M-length sequence + The dependent data --- nominally f(xdata, ...) + + :param weight: M-length sequence + Weights to be applied in the calculation of chi square + As a reminder ``chisq = np.sum(weigth * (model(x, *parameters) - y)**2)`` + + :param constraints: + If provided, it is a 2D sequence of dimension (n_parameters, 3) where, + for each parameter denoted by the index i, the meaning is + + - constraints[i][0] + + - 0 - Free (CFREE) + - 1 - Positive (CPOSITIVE) + - 2 - Quoted (CQUOTED) + - 3 - Fixed (CFIXED) + - 4 - Factor (CFACTOR) + - 5 - Delta (CDELTA) + - 6 - Sum (CSUM) + + + - constraints[i][1] + + - Ignored if constraints[i][0] is 0, 1, 3 + - Min value of the parameter if constraints[i][0] is CQUOTED + - Index of fitted parameter to which it is related + + - constraints[i][2] + + - Ignored if constraints[i][0] is 0, 1, 3 + - Max value of the parameter if constraints[i][0] is CQUOTED + - Factor to apply to related parameter with index constraints[i][1] + - Difference with parameter with index constraints[i][1] + - Sum obtained when adding parameter with index constraints[i][1] + :type constraints: *optional*, None or 2D sequence + + :param model_deriv: + None (default) or function providing the derivatives of the fitting function respect to the fitted parameters. + It will be called as model_deriv(xdata, parameters, index) where parameters is a sequence with the current + values of the fitting parameters, index is the fitting parameter index for which the the derivative has + to be provided in the supplied array of xdata points. + :type model_deriv: *optional*, None or callable + + + :param epsfcn: float + A variable used in determining a suitable parameter variation when + calculating the numerical derivatives (for model_deriv=None). + Normally the actual step length will be sqrt(epsfcn)*x + Original Gefit module was using epsfcn 1.0e-10 while default value + is now numpy.finfo(numpy.float64).eps as in scipy + :type epsfcn: *optional*, float + + :param left_derivative: + This parameter only has an influence if no derivative function + is provided. When True the left and right derivatives of the + model will be calculated for each fitted parameters thus leading to + the double number of function evaluations. Default is False. + Original Gefit module was always using left_derivative as True. + :type left_derivative: *optional*, bool + + :param last_evaluation: An M-length array + Used for optimization purposes. If supplied, this array will be taken as the result of + evaluating the function, that is as the result of ``model(x, *parameters)`` thus avoiding + the evaluation call. + + :param full_output: bool, optional + Additional output used for internal purposes with the keys: + ``function_calls`` + The number of model function calls performed. + ``fitparam`` + A sequence with the actual free parameters + ``free_index`` + Sequence with the indices of the free parameters in input parameters sequence. + ``noigno`` + Sequence with the indices of the original parameters considered in the calculations. + """ + if epsfcn is None: + epsfcn = numpy.finfo(numpy.float64).eps + else: + epsfcn = max(epsfcn, numpy.finfo(numpy.float64).eps) + #nr0, nc = data.shape + n_param = len(parameters) + if constraints is None: + derivfactor = numpy.ones((n_param, )) + n_free = n_param + noigno = numpy.arange(n_param) + free_index = noigno * 1 + fitparam = parameters * 1 + else: + n_free = 0 + fitparam = [] + free_index = [] + noigno = [] + derivfactor = [] + for i in range(n_param): + if constraints[i][0] != CIGNORED: + noigno.append(i) + if constraints[i][0] == CFREE: + fitparam.append(parameters [i]) + derivfactor.append(1.0) + free_index.append(i) + n_free += 1 + elif constraints[i][0] == CPOSITIVE: + fitparam.append(abs(parameters[i])) + derivfactor.append(1.0) + #fitparam.append(numpy.sqrt(abs(parameters[i]))) + #derivfactor.append(2.0*numpy.sqrt(abs(parameters[i]))) + free_index.append(i) + n_free += 1 + elif constraints[i][0] == CQUOTED: + pmax = max(constraints[i][1], constraints[i][2]) + pmin =min(constraints[i][1], constraints[i][2]) + if ((pmax-pmin) > 0) & \ + (parameters[i] <= pmax) & \ + (parameters[i] >= pmin): + A = 0.5 * (pmax + pmin) + B = 0.5 * (pmax - pmin) + fitparam.append(parameters[i]) + derivfactor.append(B*numpy.cos(numpy.arcsin((parameters[i] - A)/B))) + free_index.append(i) + n_free += 1 + elif (pmax-pmin) > 0: + print("WARNING: Quoted parameter outside boundaries") + print("Initial value = %f" % parameters[i]) + print("Limits are %f and %f" % (pmin, pmax)) + print("Parameter will be kept at its starting value") + fitparam = numpy.array(fitparam, numpy.float64) + alpha = numpy.zeros((n_free, n_free), numpy.float64) + beta = numpy.zeros((1, n_free), numpy.float64) + #delta = (fitparam + numpy.equal(fitparam, 0.0)) * 0.00001 + delta = (fitparam + numpy.equal(fitparam, 0.0)) * numpy.sqrt(epsfcn) + nr = y.size + ############## + # Prior to each call to the function one has to re-calculate the + # parameters + pwork = parameters.__copy__() + for i in range(n_free): + pwork [free_index[i]] = fitparam [i] + if n_free == 0: + raise ValueError("No free parameters to fit") + function_calls = 0 + if not left_derivative: + if last_evaluation is not None: + f2 = last_evaluation + else: + f2 = model(x, *parameters) + f2.shape = -1 + function_calls += 1 + for i in range(n_free): + if model_deriv is None: + #pwork = parameters.__copy__() + pwork[free_index[i]] = fitparam [i] + delta [i] + newpar = _get_parameters(pwork.tolist(), constraints) + newpar = numpy.take(newpar, noigno) + f1 = model(x, *newpar) + f1.shape = -1 + function_calls += 1 + if left_derivative: + pwork[free_index[i]] = fitparam [i] - delta [i] + newpar = _get_parameters(pwork.tolist(), constraints) + newpar=numpy.take(newpar, noigno) + f2 = model(x, *newpar) + function_calls += 1 + help0 = (f1 - f2) / (2.0 * delta[i]) + else: + help0 = (f1 - f2) / (delta[i]) + help0 = help0 * derivfactor[i] + pwork[free_index[i]] = fitparam [i] + #removed I resize outside the loop: + #help0 = numpy.resize(help0, (1, nr)) + else: + help0 = model_deriv(x, pwork, free_index[i]) + help0 = help0 * derivfactor[i] + + if i == 0: + deriv = help0 + else: + deriv = numpy.concatenate((deriv, help0), 0) + + #line added to resize outside the loop + deriv = numpy.resize(deriv, (n_free, nr)) + if last_evaluation is None: + if constraints is None: + yfit = model(x, *fitparam) + yfit.shape = -1 + else: + newpar = _get_parameters(pwork.tolist(), constraints) + newpar = numpy.take(newpar, noigno) + yfit = model(x, *newpar) + yfit.shape = -1 + function_calls += 1 + else: + yfit = last_evaluation + deltay = y - yfit + help0 = weight * deltay + for i in range(n_free): + derivi = numpy.resize(deriv[i, :], (1, nr)) + help1 = numpy.resize(numpy.sum((help0 * derivi), 1), (1, 1)) + if i == 0: + beta = help1 + else: + beta = numpy.concatenate((beta, help1), 1) + help1 = numpy.inner(deriv, weight*derivi) + if i == 0: + alpha = help1 + else: + alpha = numpy.concatenate((alpha, help1), 1) + chisq = (help0 * deltay).sum() + if full_output: + ddict = {} + ddict["n_free"] = n_free + ddict["free_index"] = free_index + ddict["noigno"] = noigno + ddict["fitparam"] = fitparam + ddict["derivfactor"] = derivfactor + ddict["function_calls"] = function_calls + return chisq, alpha, beta, ddict + else: + return chisq, alpha, beta + + +def _get_parameters(parameters, constraints): + """ + Apply constraints to input parameters. + + Parameters not depending on other parameters, they are returned as the input. + + Parameters depending on other parameters, return the value after applying the + relation to the parameter wo which they are related. + """ + # 0 = Free 1 = Positive 2 = Quoted + # 3 = Fixed 4 = Factor 5 = Delta + if constraints is None: + return parameters * 1 + newparam = [] + #first I make the free parameters + #because the quoted ones put troubles + for i in range(len(constraints)): + if constraints[i][0] == CFREE: + newparam.append(parameters[i]) + elif constraints[i][0] == CPOSITIVE: + #newparam.append(parameters[i] * parameters[i]) + newparam.append(abs(parameters[i])) + elif constraints[i][0] == CQUOTED: + newparam.append(parameters[i]) + elif abs(constraints[i][0]) == CFIXED: + newparam.append(parameters[i]) + else: + newparam.append(parameters[i]) + for i in range(len(constraints)): + if constraints[i][0] == CFACTOR: + newparam[i] = constraints[i][2] * newparam[int(constraints[i][1])] + elif constraints[i][0] == CDELTA: + newparam[i] = constraints[i][2] + newparam[int(constraints[i][1])] + elif constraints[i][0] == CIGNORED: + # The whole ignored stuff should not be documented because setting + # a parameter to 0 is not the same as being ignored. + # Being ignored should imply the parameter is simply not accounted for + # and should be stripped out of the list of parameters by the program + # using this module + newparam[i] = 0 + elif constraints[i][0] == CSUM: + newparam[i] = constraints[i][2]-newparam[int(constraints[i][1])] + return newparam + + +def _get_sigma_parameters(parameters, sigma0, constraints): + """ + Internal function propagating the uncertainty on the actually fitted parameters and related parameters to the + final parameters considering the applied constraints. + + Parameters + ---------- + parameters : 1D sequence of length equal to the number of free parameters N + The parameters actually used in the fitting process. + sigma0 : 1D sequence of length N + Uncertainties calculated as the square-root of the diagonal of + the covariance matrix + constraints : The set of constraints applied in the fitting process + """ + # 0 = Free 1 = Positive 2 = Quoted + # 3 = Fixed 4 = Factor 5 = Delta + if constraints is None: + return sigma0 + n_free = 0 + sigma_par = numpy.zeros(parameters.shape, numpy.float64) + for i in range(len(constraints)): + if constraints[i][0] == CFREE: + sigma_par [i] = sigma0[n_free] + n_free += 1 + elif constraints[i][0] == CPOSITIVE: + #sigma_par [i] = 2.0 * sigma0[n_free] + sigma_par [i] = sigma0[n_free] + n_free += 1 + elif constraints[i][0] == CQUOTED: + pmax = max(constraints [i][1], constraints [i][2]) + pmin = min(constraints [i][1], constraints [i][2]) + # A = 0.5 * (pmax + pmin) + B = 0.5 * (pmax - pmin) + if (B > 0) & (parameters [i] < pmax) & (parameters [i] > pmin): + sigma_par [i] = abs(B * numpy.cos(parameters[i]) * sigma0[n_free]) + n_free += 1 + else: + sigma_par [i] = parameters[i] + elif abs(constraints[i][0]) == CFIXED: + sigma_par[i] = parameters[i] + for i in range(len(constraints)): + if constraints[i][0] == CFACTOR: + sigma_par [i] = constraints[i][2]*sigma_par[int(constraints[i][1])] + elif constraints[i][0] == CDELTA: + sigma_par [i] = sigma_par[int(constraints[i][1])] + elif constraints[i][0] == CSUM: + sigma_par [i] = sigma_par[int(constraints[i][1])] + return sigma_par + + +def main(argv=None): + if argv is None: + npoints = 10000 + elif hasattr(argv, "__len__"): + if len(argv) > 1: + npoints = int(argv[1]) + else: + print("Usage:") + print("fit [npoints]") + else: + # expected a number + npoints = argv + + def gauss(t0, *param0): + param = numpy.array(param0) + t = numpy.array(t0) + dummy = 2.3548200450309493 * (t - param[3]) / param[4] + return param[0] + param[1] * t + param[2] * myexp(-0.5 * dummy * dummy) + + + def myexp(x): + # put a (bad) filter to avoid over/underflows + # with no python looping + return numpy.exp(x * numpy.less(abs(x), 250)) -\ + 1.0 * numpy.greater_equal(abs(x), 250) + + xx = numpy.arange(npoints, dtype=numpy.float64) + yy = gauss(xx, *[10.5, 2, 1000.0, 20., 15]) + sy = numpy.sqrt(abs(yy)) + parameters = [0.0, 1.0, 900.0, 25., 10] + stime = time.time() + + fittedpar, cov, ddict = leastsq(gauss, xx, yy, parameters, + sigma=sy, + left_derivative=False, + full_output=True, + check_finite=True) + etime = time.time() + sigmapars = numpy.sqrt(numpy.diag(cov)) + print("Took ", etime - stime, "seconds") + print("Function calls = ", ddict["nfev"]) + print("chi square = ", ddict["chisq"]) + print("Fitted pars = ", fittedpar) + print("Sigma pars = ", sigmapars) + try: + from scipy.optimize import curve_fit as cfit + SCIPY = True + except ImportError: + SCIPY = False + if SCIPY: + counter = 0 + stime = time.time() + scipy_fittedpar, scipy_cov = cfit(gauss, + xx, + yy, + parameters, + sigma=sy) + etime = time.time() + print("Scipy Took ", etime - stime, "seconds") + print("Counter = ", counter) + print("scipy = ", scipy_fittedpar) + print("Sigma = ", numpy.sqrt(numpy.diag(scipy_cov))) + +if __name__ == "__main__": + main() diff --git a/src/silx/math/fit/peaks.pyx b/src/silx/math/fit/peaks.pyx new file mode 100644 index 0000000..a4fce89 --- /dev/null +++ b/src/silx/math/fit/peaks.pyx @@ -0,0 +1,175 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +"""This module provides a peak search function and tools related to peak +analysis. +""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "15/05/2017" + +import logging +import numpy + +from silx.math.fit import filters + +_logger = logging.getLogger(__name__) + +cimport cython +from libc.stdlib cimport free + +cimport silx.math.fit.peaks_wrapper as peaks_wrapper + + +def peak_search(y, fwhm, sensitivity=3.5, + begin_index=None, end_index=None, + debug=False, relevance_info=False): + """Find peaks in a curve. + + :param y: Data array + :type y: numpy.ndarray + :param fwhm: Estimated full width at half maximum of the typical peaks we + are interested in (expressed in number of samples) + :param sensitivity: Threshold factor used for peak detection. Only peaks + with amplitudes higher than ``σ * sensitivity`` - where ``σ`` is the + standard deviation of the noise - qualify as peaks. + :param begin_index: Index of the first sample of the region of interest + in the ``y`` array. If ``None``, start from the first sample. + :param end_index: Index of the last sample of the region of interest in + the ``y`` array. If ``None``, process until the last sample. + :param debug: If ``True``, print debug messages. Default: ``False`` + :param relevance_info: If ``True``, add a second dimension with relevance + information to the output array. Default: ``False`` + :return: 1D sequence with indices of peaks in the data + if ``relevance_info`` is ``False``. + Else, sequence of ``(peak_index, peak_relevance)`` tuples (one tuple + per peak). + :raise: ``IndexError`` if the number of peaks is too large to fit in the + output array. + """ + cdef: + int i + double[::1] y_c + double* peaks_c + double* relevances_c + + y_c = numpy.array(y, + copy=True, + dtype=numpy.float64, + order='C').reshape(-1) + if debug: + debug = 1 + else: + debug = 0 + + if begin_index is None: + begin_index = 0 + if end_index is None: + end_index = y_c.size - 1 + + n_peaks = peaks_wrapper.seek(begin_index, end_index, y_c.size, + fwhm, sensitivity, debug, + &y_c[0], &peaks_c, &relevances_c) + + + # A negative return value means that peaks were found but not enough + # memory could be allocated for all + if n_peaks < 0 and n_peaks != -123456: + msg = "Before memory allocation error happened, " + msg += "we found %d peaks.\n" % abs(n_peaks) + _logger.debug(msg) + msg = "" + for i in range(abs(n_peaks)): + msg += "peak index %f, " % peaks_c[i] + msg += "relevance %f\n" % relevances_c[i] + _logger.debug(msg) + free(peaks_c) + free(relevances_c) + raise MemoryError("Failed to reallocate memory for output arrays") + # Special value -123456 is returned if the initial memory allocation + # fails, before any search could be performed + elif n_peaks == -123456: + raise MemoryError("Failed to allocate initial memory for " + + "output arrays") + + peaks = numpy.empty(shape=(n_peaks,), + dtype=numpy.float64) + relevances = numpy.empty(shape=(n_peaks,), + dtype=numpy.float64) + + for i in range(n_peaks): + peaks[i] = peaks_c[i] + relevances[i] = relevances_c[i] + + free(peaks_c) + free(relevances_c) + + if not relevance_info: + return peaks + else: + return list(zip(peaks, relevances)) + + +def guess_fwhm(y): + """Return the full-width at half maximum for the largest peak in + the data array. + + The algorithm removes the background, then finds a global maximum + and its corresponding FWHM. + + This value can be used as an initial fit parameter, used as input for + an iterative fit function. + + :param y: Data to be used for guessing the fwhm. + :return: Estimation of full-width at half maximum, based on fwhm of + the global maximum. + """ + # set at a minimum value for the fwhm + fwhm_min = 4 + + # remove data background (computed with a strip filter) + background = filters.strip(y, w=1, niterations=1000) + yfit = y - background + + # basic peak search: find the global maximum + maximum = max(yfit) + # find indices of all values == maximum + idx = numpy.nonzero(yfit == maximum)[0] + # take the last one (if any) + if not len(idx): + return 0 + posindex = idx[-1] + height = yfit[posindex] + + # now find the width of the peak at half maximum + imin = posindex + while yfit[imin] > 0.5 * height and imin > 0: + imin -= 1 + imax = posindex + while yfit[imax] > 0.5 * height and imax < len(yfit) - 1: + imax += 1 + + fwhm = max(imax - imin - 1, fwhm_min) + + return fwhm diff --git a/src/silx/math/fit/peaks/include/peaks.h b/src/silx/math/fit/peaks/include/peaks.h new file mode 100644 index 0000000..bd25d96 --- /dev/null +++ b/src/silx/math/fit/peaks/include/peaks.h @@ -0,0 +1,32 @@ +/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +#ifndef PEAKS_H +#define PEAKS_H + +/* Smoothing functions */ + +long seek(long begin_index, long end_index, long nsamples, double fwhm, double sensitivity, + double debug_info, double *data, double **peaks, double **relevances); + +#endif /* #define PEAKS_H */ diff --git a/src/silx/math/fit/peaks/src/peaks.c b/src/silx/math/fit/peaks/src/peaks.c new file mode 100644 index 0000000..65cb4f6 --- /dev/null +++ b/src/silx/math/fit/peaks/src/peaks.c @@ -0,0 +1,255 @@ +#/*########################################################################## +# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ +#include <math.h> +#include <stdlib.h> +#include <stdio.h> +#include "peaks.h" + + +#define MIN(x, y) (((x) < (y)) ? (x) : (y)) +#define MAX(x, y) (((x) > (y)) ? (x) : (y)) + +/* Peak search function, adapted from PyMca SpecFitFuns + + This uses a convolution with the second-derivative of a gaussian curve, to + smooth the data. + + Arguments: + + - begin_index: First index of the region of interest in the input data + array + - end_index: Last index of the region of interest in the input data + array + - nsamples: Number of samples in the input array + - fwhm: Full width at half maximum for the gaussian used for smoothing. + - sensitivity: + - debug_info: If different from 0, print debugging messages + - data: input array of 1D data + - peaks: pointer to output array of peak indices + - relevances: pointer to output array of peak relevances +*/ +long seek(long begin_index, + long end_index, + long nsamples, + double fwhm, + double sensitivity, + double debug_info, + double *data, + double **peaks, + double **relevances) +{ + /* local variables */ + double *peaks0, *relevances0; + double *realloc_peaks, *realloc_relevances; + double sigma, sigma2, sigma4; + long max_gfactor = 100; + double gfactor[100]; + long nr_factor; + double lowthreshold; + double data2[2]; + double nom; + double den2; + long channel1; + long lld; + long cch; + long cfac, cfac2, max_cfac; + long ihelp1, ihelp2; + long i; + long max_npeaks = 100; + long n_peaks = 0; + double peakstarted = 0; + + peaks0 = malloc(100 * sizeof(double)); + relevances0 = malloc(100 * sizeof(double)); + if (peaks0 == NULL || relevances0 == NULL) { + printf("Error: failed to allocate memory for peaks array."); + return(-123456); + } + /* Make sure the peaks matrix is filled with zeros */ + for (i=0;i<100;i++){ + peaks0[i] = 0.0; + relevances0[i] = 0.0; + } + /* Output pointers */ + *peaks = peaks0; + *relevances = relevances0; + + /* prepare the calculation of the Gaussian scaling factors */ + + sigma = fwhm / 2.35482; + sigma2 = sigma * sigma; + sigma4 = sigma2 * sigma2; + lowthreshold = 0.01 / sigma2; + + /* calculate the factors until lower threshold reached */ + nr_factor = 0; + max_cfac = MIN(max_gfactor, ((end_index - begin_index - 2) / 2) - 1); + for (cfac=0; cfac < max_cfac; cfac++) { + nr_factor++; + cfac2 = (cfac+1) * (cfac+1); + gfactor[cfac] = (sigma2 - cfac2) * exp(-cfac2/(sigma2*2.0)) / sigma4; + + if ((gfactor[cfac] < lowthreshold) + && (gfactor[cfac] > (-lowthreshold))){ + break; + } + } + + /* What comes now is specific to MCA spectra ... */ + lld = 0; + while (data[lld] == 0) { + lld++; + } + lld = lld + (int) (0.5 * fwhm); + + channel1 = begin_index - nr_factor - 1; + channel1 = MAX (channel1, lld); + if(debug_info){ + printf("nrfactor = %ld\n", nr_factor); + } + /* calculates smoothed value and variance at begincalc */ + cch = MAX(begin_index, 0); + nom = data[cch] / sigma2; + den2 = data[cch] / sigma4; + for (cfac = 0; cfac < nr_factor; cfac++){ + ihelp1 = cch-cfac; + if (ihelp1 < 0){ + ihelp1 = 0; + } + ihelp2 = cch+cfac; + if (ihelp2 >= nsamples){ + ihelp2 = nsamples-1; + } + nom += gfactor[cfac] * (data[ihelp2] + data[ihelp1]); + den2 += gfactor[cfac] * gfactor[cfac] * + (data[ihelp2] + data[ihelp1]); + } + + /* now normalize the smoothed value to the standard deviation */ + if (den2 <= 0.0) { + data2[1] = 0.0; + }else{ + data2[1] = nom / sqrt(den2); + } + data[0] = data[1]; + + while (cch <= MIN(end_index,nsamples-2)){ + /* calculate gaussian smoothed values */ + data2[0] = data2[1]; + cch++; + nom = data[cch]/sigma2; + den2 = data[cch] / sigma4; + for (cfac = 1; cfac < nr_factor; cfac++){ + ihelp1 = cch-cfac; + if (ihelp1 < 0){ + ihelp1 = 0; + } + ihelp2 = cch+cfac; + if (ihelp2 >= nsamples){ + ihelp2 = nsamples-1; + } + nom += gfactor[cfac-1] * (data[ihelp2] + data[ihelp1]); + den2 += gfactor[cfac-1] * gfactor[cfac-1] * + (data[ihelp2] + data[ihelp1]); + } + /* now normalize the smoothed value to the standard deviation */ + if (den2 <= 0) { + data2[1] = 0; + }else{ + data2[1] = nom / sqrt(den2); + } + /* look if the current point falls in a peak */ + if (data2[1] > sensitivity) { + if(peakstarted == 0){ + if (data2[1] > data2[0]){ + /* this second test is to prevent a peak from outside + the region from being detected at the beginning of the search */ + peakstarted=1; + } + } + /* there is a peak */ + if (debug_info){ + printf("At cch = %ld y[cch] = %g\n", cch, data[cch]); + printf("data2[0] = %g\n", data2[0]); + printf("data2[1] = %g\n", data2[1]); + printf("sensitivity = %g\n", sensitivity); + } + if(peakstarted == 1){ + /* look for the top of the peak */ + if (data2[1] < data2[0]) { + /* we are close to the top of the peak */ + if (debug_info){ + printf("we are close to the top of the peak\n"); + } + if (n_peaks == max_npeaks) { + max_npeaks = max_npeaks + 100; + realloc_peaks = realloc(peaks0, max_npeaks * sizeof(double)); + realloc_relevances = realloc(relevances0, max_npeaks * sizeof(double)); + if (realloc_peaks == NULL || realloc_relevances == NULL) { + printf("Error: failed to extend memory for peaks array."); + *peaks = peaks0; + *relevances = relevances0; + return(-n_peaks); + } + else { + peaks0 = realloc_peaks; + relevances0 = realloc_relevances; + } + } + peaks0[n_peaks] = cch-1; + relevances0[n_peaks] = data2[0]; + n_peaks++; + peakstarted=2; + } + } + /* Doublet case */ + if(peakstarted == 2){ + if ((cch-peaks0[n_peaks-1]) > 0.6 * fwhm) { + if (data2[1] > data2[0]){ + if(debug_info){ + printf("We may have a doublet\n"); + } + peakstarted=1; + } + } + } + }else{ + if (peakstarted==1){ + /* We were on a peak but we did not find the top */ + if(debug_info){ + printf("We were on a peak but we did not find the top\n"); + } + } + peakstarted=0; + } + } + if(debug_info){ + for (i=0;i< n_peaks;i++){ + printf("Peak %ld found at ",i+1); + printf("index %g with y = %g\n", peaks0[i],data[(long ) peaks0[i]]); + } + } + *peaks = peaks0; + *relevances = relevances0; + return (n_peaks); +} diff --git a/src/silx/math/fit/peaks_wrapper.pxd b/src/silx/math/fit/peaks_wrapper.pxd new file mode 100644 index 0000000..4c77dc6 --- /dev/null +++ b/src/silx/math/fit/peaks_wrapper.pxd @@ -0,0 +1,41 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#############################################################################*/ + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "22/06/2016" + +cimport cython + +cdef extern from "peaks.h": + long seek(long begin_index, + long end_index, + long nsamples, + double fwhm, + double sensitivity, + double debug_info, + double * data, + double ** peaks, + double ** relevances) + diff --git a/src/silx/math/fit/setup.py b/src/silx/math/fit/setup.py new file mode 100644 index 0000000..649387f --- /dev/null +++ b/src/silx/math/fit/setup.py @@ -0,0 +1,85 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "22/06/2016" + + +import os.path + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('fit', parent_package, top_path) + config.add_subpackage('test') + + # ===================================== + # fit functions + # ===================================== + fun_src = [os.path.join('functions', "src", "funs.c"), + "functions.pyx"] + fun_inc = [os.path.join('functions', 'include')] + + config.add_extension('functions', + sources=fun_src, + include_dirs=fun_inc, + language='c') + + # ===================================== + # fit filters + # ===================================== + filt_src = [os.path.join('filters', "src", srcf) + for srcf in ["smoothnd.c", "snip1d.c", + "snip2d.c", "snip3d.c", "strip.c"]] + filt_src.append("filters.pyx") + filt_inc = [os.path.join('filters', 'include')] + + config.add_extension('filters', + sources=filt_src, + include_dirs=filt_inc, + language='c') + + # ===================================== + # peaks + # ===================================== + peaks_src = [os.path.join('peaks', "src", "peaks.c"), + "peaks.pyx"] + peaks_inc = [os.path.join('peaks', 'include')] + + config.add_extension('peaks', + sources=peaks_src, + include_dirs=peaks_inc, + language='c') + # ===================================== + # ===================================== + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + + setup(configuration=configuration) diff --git a/src/silx/math/fit/test/__init__.py b/src/silx/math/fit/test/__init__.py new file mode 100644 index 0000000..745efe3 --- /dev/null +++ b/src/silx/math/fit/test/__init__.py @@ -0,0 +1,23 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ diff --git a/src/silx/math/fit/test/test_bgtheories.py b/src/silx/math/fit/test/test_bgtheories.py new file mode 100644 index 0000000..6620d38 --- /dev/null +++ b/src/silx/math/fit/test/test_bgtheories.py @@ -0,0 +1,154 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +import copy +import unittest +import numpy +import random + +from silx.math.fit import bgtheories +from silx.math.fit.functions import sum_gauss + + +class TestBgTheories(unittest.TestCase): + """ + """ + def setUp(self): + self.x = numpy.arange(100) + self.y = 10 + 0.05 * self.x + sum_gauss(self.x, 10., 45., 15.) + # add a very narrow high amplitude peak to test strip and snip + self.y += sum_gauss(self.x, 100., 75., 2.) + self.narrow_peak_index = list(self.x).index(75) + random.seed() + + def tearDown(self): + pass + + def testTheoriesAttrs(self): + for theory_name in bgtheories.THEORY: + self.assertIsInstance(theory_name, str) + self.assertTrue(hasattr(bgtheories.THEORY[theory_name], + "function")) + self.assertTrue(hasattr(bgtheories.THEORY[theory_name].function, + "__call__")) + # Ensure legacy functions are not renamed accidentally + self.assertTrue( + {"No Background", "Constant", "Linear", "Strip", "Snip"}.issubset( + set(bgtheories.THEORY))) + + def testNoBg(self): + nobgfun = bgtheories.THEORY["No Background"].function + self.assertTrue(numpy.array_equal(nobgfun(self.x, self.y), + numpy.zeros_like(self.x))) + # default estimate + self.assertEqual(bgtheories.THEORY["No Background"].estimate(self.x, self.y), + ([], [])) + + def testConstant(self): + consfun = bgtheories.THEORY["Constant"].function + c = random.random() * 100 + self.assertTrue(numpy.array_equal(consfun(self.x, self.y, c), + c * numpy.ones_like(self.x))) + # default estimate + esti_par, cons = bgtheories.THEORY["Constant"].estimate(self.x, self.y) + self.assertEqual(cons, + [[0, 0, 0]]) + self.assertAlmostEqual(esti_par, + min(self.y)) + + def testLinear(self): + linfun = bgtheories.THEORY["Linear"].function + a = random.random() * 100 + b = random.random() * 100 + self.assertTrue(numpy.array_equal(linfun(self.x, self.y, a, b), + a + b * self.x)) + # default estimate + esti_par, cons = bgtheories.THEORY["Linear"].estimate(self.x, self.y) + + self.assertEqual(cons, + [[0, 0, 0], [0, 0, 0]]) + self.assertAlmostEqual(esti_par[0], 10, places=3) + self.assertAlmostEqual(esti_par[1], 0.05, places=3) + + def testStrip(self): + stripfun = bgtheories.THEORY["Strip"].function + anchors = sorted(random.sample(list(self.x), 4)) + anchors_indices = [list(self.x).index(a) for a in anchors] + + # we really want to strip away the narrow peak + anchors_indices_copy = copy.deepcopy(anchors_indices) + for idx in anchors_indices_copy: + if abs(idx - self.narrow_peak_index) < 5: + anchors_indices.remove(idx) + anchors.remove(self.x[idx]) + + width = 2 + niter = 1000 + bgtheories.THEORY["Strip"].configure(AnchorsList=anchors, AnchorsFlag=True) + + bg = stripfun(self.x, self.y, width, niter) + + # assert peak amplitude has been decreased + self.assertLess(bg[self.narrow_peak_index], + self.y[self.narrow_peak_index]) + + # default estimate + for i in anchors_indices: + self.assertEqual(bg[i], self.y[i]) + + # estimated parameters are equal to the default ones in the config dict + bgtheories.THEORY["Strip"].configure(StripWidth=7, StripIterations=8) + esti_par, cons = bgtheories.THEORY["Strip"].estimate(self.x, self.y) + self.assertTrue(numpy.array_equal(cons, [[3, 0, 0], [3, 0, 0]])) + self.assertEqual(esti_par, [7, 8]) + + def testSnip(self): + snipfun = bgtheories.THEORY["Snip"].function + anchors = sorted(random.sample(list(self.x), 4)) + anchors_indices = [list(self.x).index(a) for a in anchors] + + # we want to strip away the narrow peak, so remove nearby anchors + anchors_indices_copy = copy.deepcopy(anchors_indices) + for idx in anchors_indices_copy: + if abs(idx - self.narrow_peak_index) < 5: + anchors_indices.remove(idx) + anchors.remove(self.x[idx]) + + width = 16 + bgtheories.THEORY["Snip"].configure(AnchorsList=anchors, AnchorsFlag=True) + bg = snipfun(self.x, self.y, width) + + # assert peak amplitude has been decreased + self.assertLess(bg[self.narrow_peak_index], + self.y[self.narrow_peak_index], + "Snip didn't decrease the peak amplitude.") + + # anchored data must remain fixed + for i in anchors_indices: + self.assertEqual(bg[i], self.y[i]) + + # estimated parameters are equal to the default ones in the config dict + bgtheories.THEORY["Snip"].configure(SnipWidth=7) + esti_par, cons = bgtheories.THEORY["Snip"].estimate(self.x, self.y) + self.assertTrue(numpy.array_equal(cons, [[3, 0, 0]])) + self.assertEqual(esti_par, [7]) diff --git a/src/silx/math/fit/test/test_filters.py b/src/silx/math/fit/test/test_filters.py new file mode 100644 index 0000000..8314bdc --- /dev/null +++ b/src/silx/math/fit/test/test_filters.py @@ -0,0 +1,122 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +import numpy +import unittest +from silx.math.fit import filters +from silx.math.fit import functions +from silx.test.utils import add_relative_noise + + +class TestSmooth(unittest.TestCase): + """ + Unit tests of smoothing functions. + + Test that the difference between a synthetic curve with 5% added random + noise and the result of smoothing that signal is less than 5%. We compare + the sum of all samples in each curve. + """ + def setUp(self): + x = numpy.arange(5000) + # (height1, center1, fwhm1, beamfwhm...) + slit_params = (50, 500, 200, 100, + 50, 600, 80, 30, + 20, 2000, 150, 150, + 50, 2250, 110, 100, + 40, 3000, 50, 10, + 23, 4980, 250, 20) + + self.y1 = functions.sum_slit(x, *slit_params) + # 5% noise + self.y1 = add_relative_noise(self.y1, 5.) + + # (height1, center1, fwhm1...) + step_params = (50, 500, 200, + 50, 600, 80, + 20, 2000, 150, + 50, 2250, 110, + 40, 3000, 50, + 23, 4980, 250,) + + self.y2 = functions.sum_stepup(x, *step_params) + # 5% noise + self.y2 = add_relative_noise(self.y2, 5.) + + self.y3 = functions.sum_stepdown(x, *step_params) + # 5% noise + self.y3 = add_relative_noise(self.y3, 5.) + + def tearDown(self): + pass + + def testSavitskyGolay(self): + npts = 25 + for y in [self.y1, self.y2, self.y3]: + smoothed_y = filters.savitsky_golay(y, npoints=npts) + + # we added +-5% of random noise. The difference must be much lower + # than 5%. + diff = abs(sum(smoothed_y) - sum(y)) / sum(y) + self.assertLess(diff, 0.05, + "Difference between data with 5%% noise and " + + "smoothed data is > 5%% (%f %%)" % (diff * 100)) + + # Try various smoothing levels + npts += 25 + + def testSmooth1d(self): + """Test the 1D smoothing against the formula + ys[i] = (y[i-1] + 2 * y[i] + y[i+1]) / 4 (for 1 < i < n-1)""" + smoothed_y = filters.smooth1d(self.y1) + + for i in range(1, len(self.y1) - 1): + self.assertAlmostEqual(4 * smoothed_y[i], + self.y1[i-1] + 2 * self.y1[i] + self.y1[i+1]) + + def testSmooth2d(self): + """Test that a 2D smoothing is the same as two successive and + orthogonal 1D smoothings""" + x = numpy.arange(10000) + + noise = 2 * numpy.random.random(10000) - 1 + noise *= 0.05 + y = x * (1 + noise) + + y.shape = (100, 100) + + smoothed_y = filters.smooth2d(y) + + intermediate_smooth = numpy.zeros_like(y) + expected_smooth = numpy.zeros_like(y) + # smooth along first dimension + for i in range(0, y.shape[0]): + intermediate_smooth[i, :] = filters.smooth1d(y[i, :]) + + # smooth along second dimension + for j in range(0, y.shape[1]): + expected_smooth[:, j] = filters.smooth1d(intermediate_smooth[:, j]) + + for i in range(0, y.shape[0]): + for j in range(0, y.shape[1]): + self.assertAlmostEqual(smoothed_y[i, j], + expected_smooth[i, j]) diff --git a/src/silx/math/fit/test/test_fit.py b/src/silx/math/fit/test/test_fit.py new file mode 100644 index 0000000..00f04e2 --- /dev/null +++ b/src/silx/math/fit/test/test_fit.py @@ -0,0 +1,373 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Nominal tests of the leastsq function. +""" + +import unittest + +import numpy +import sys + +from silx.utils import testutils +from silx.math.fit.leastsq import _logger as fitlogger + + +class Test_leastsq(unittest.TestCase): + """ + Unit tests of the leastsq function. + """ + + ndims = None + + def setUp(self): + try: + from silx.math.fit import leastsq + self.instance = leastsq + except ImportError: + self.instance = None + + def myexp(x): + # put a (bad) filter to avoid over/underflows + # with no python looping + with numpy.errstate(invalid='ignore'): + return numpy.exp(x*numpy.less(abs(x), 250)) - \ + 1.0 * numpy.greater_equal(abs(x), 250) + + self.my_exp = myexp + + def gauss(x, *params): + params = numpy.array(params, copy=False, dtype=numpy.float64) + result = params[0] + params[1] * x + for i in range(2, len(params), 3): + p = params[i:(i+3)] + dummy = 2.3548200450309493*(x - p[1])/p[2] + result += p[0] * self.my_exp(-0.5 * dummy * dummy) + return result + + self.gauss = gauss + + def gauss_derivative(x, params, idx): + if idx == 0: + return numpy.ones(len(x), numpy.float64) + if idx == 1: + return x + gaussian_peak = (idx - 2) // 3 + gaussian_parameter = (idx - 2) % 3 + actual_idx = 2 + 3 * gaussian_peak + p = params[actual_idx:(actual_idx+3)] + if gaussian_parameter == 0: + return self.gauss(x, *[0, 0, 1.0, p[1], p[2]]) + if gaussian_parameter == 1: + tmp = self.gauss(x, *[0, 0, p[0], p[1], p[2]]) + tmp *= 2.3548200450309493*(x - p[1])/p[2] + return tmp * 2.3548200450309493/p[2] + if gaussian_parameter == 2: + tmp = self.gauss(x, *[0, 0, p[0], p[1], p[2]]) + tmp *= 2.3548200450309493*(x - p[1])/p[2] + return tmp * 2.3548200450309493*(x - p[1])/(p[2]*p[2]) + + self.gauss_derivative = gauss_derivative + + def tearDown(self): + self.instance = None + self.gauss = None + self.gauss_derivative = None + self.my_exp = None + self.model_function = None + self.model_derivative = None + + def testImport(self): + self.assertTrue(self.instance is not None, + "Cannot import leastsq from silx.math.fit") + + def testUnconstrainedFitNoWeight(self): + parameters_actual = [10.5, 2, 1000.0, 20., 15] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10] + model_function = self.gauss + + fittedpar, cov = self.instance(model_function, x, y, parameters_estimate) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + def testUnconstrainedFitWeight(self): + parameters_actual = [10.5,2,1000.0,20.,15] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + sigma = numpy.sqrt(y) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10] + model_function = self.gauss + + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + def testDerivativeFunction(self): + parameters_actual = [10.5, 2, 10000.0, 20., 150, 5000, 900., 300] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + delta = numpy.sqrt(numpy.finfo(numpy.float64).eps) + for i in range(len(parameters_actual)): + p = parameters_actual * 1 + if p[i] == 0: + delta_par = delta + else: + delta_par = p[i] * delta + if i > 2: + p[0] = 0.0 + p[1] = 0.0 + p[i] += delta_par + yPlus = self.gauss(x, *p) + p[i] = parameters_actual[i] - delta_par + yMinus = self.gauss(x, *p) + numerical_derivative = (yPlus - yMinus) / (2 * delta_par) + #numerical_derivative = (self.gauss(x, *p) - y) / delta_par + p[i] = parameters_actual[i] + derivative = self.gauss_derivative(x, p, i) + diff = numerical_derivative - derivative + test_condition = numpy.allclose(numerical_derivative, + derivative, atol=5.0e-6) + if not test_condition: + msg = "Error calculating derivative of parameter %d." % i + msg += "\n diff min = %g diff max = %g" % (diff.min(), diff.max()) + self.assertTrue(test_condition, msg) + + def testConstrainedFit(self): + CFREE = 0 + CPOSITIVE = 1 + CQUOTED = 2 + CFIXED = 3 + CFACTOR = 4 + CDELTA = 5 + CSUM = 6 + parameters_actual = [10.5, 2, 10000.0, 20., 150, 5000, 900., 300] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10, 400, 850, 200] + model_function = self.gauss + model_deriv = self.gauss_derivative + constraints_all_free = [[0, 0, 0]] * len(parameters_actual) + constraints_all_positive = [[1, 0, 0]] * len(parameters_actual) + constraints_delta_position = [[0, 0, 0]] * len(parameters_actual) + constraints_delta_position[6] = [CDELTA, 3, 880] + constraints_sum_position = constraints_all_positive * 1 + constraints_sum_position[6] = [CSUM, 3, 920] + constraints_factor = constraints_delta_position * 1 + constraints_factor[2] = [CFACTOR, 5, 2] + constraints_list = [None, + constraints_all_free, + constraints_all_positive, + constraints_delta_position, + constraints_sum_position] + + # for better code coverage, the warning recommending to set full_output + # to True when using constraints should be shown at least once + full_output = True + for index, constraints in enumerate(constraints_list): + if index == 2: + full_output = None + elif index == 3: + full_output = 0 + for model_deriv in [None, self.gauss_derivative]: + for sigma in [None, numpy.sqrt(y)]: + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + constraints=constraints, + model_deriv=model_deriv, + full_output=full_output)[:2] + full_output = True + + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + def testUnconstrainedFitAnalyticalDerivative(self): + parameters_actual = [10.5, 2, 1000.0, 20., 15] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + sigma = numpy.sqrt(y) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10] + model_function = self.gauss + model_deriv = self.gauss_derivative + + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + model_deriv=model_deriv) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + @testutils.validate_logging(fitlogger.name, warning=2) + def testBadlyShapedData(self): + parameters_actual = [10.5, 2, 1000.0, 20., 15] + x = numpy.arange(10000.).reshape(1000, 10) + y = self.gauss(x, *parameters_actual) + sigma = numpy.sqrt(y) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10] + model_function = self.gauss + + for check_finite in [True, False]: + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + check_finite=check_finite) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + @testutils.validate_logging(fitlogger.name, warning=3) + def testDataWithNaN(self): + parameters_actual = [10.5, 2, 1000.0, 20., 15] + x = numpy.arange(10000.).reshape(1000, 10) + y = self.gauss(x, *parameters_actual) + sigma = numpy.sqrt(y) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10] + model_function = self.gauss + x[500] = numpy.inf + # check default behavior + try: + self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma) + except ValueError: + info = "%s" % sys.exc_info()[1] + self.assertTrue("array must not contain inf" in info) + + # check requested behavior + try: + self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + check_finite=True) + except ValueError: + info = "%s" % sys.exc_info()[1] + self.assertTrue("array must not contain inf" in info) + + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + check_finite=False) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + # testing now with ydata containing NaN + x = numpy.arange(10000.).reshape(1000, 10) + y[500] = numpy.nan + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + check_finite=False) + + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + # testing now with sigma containing NaN + sigma[300] = numpy.nan + fittedpar, cov = self.instance(model_function, x, y, + parameters_estimate, + sigma=sigma, + check_finite=False) + test_condition = numpy.allclose(parameters_actual, fittedpar) + if not test_condition: + msg = "Unsuccessfull fit\n" + for i in range(len(fittedpar)): + msg += "Expected %g obtained %g\n" % (parameters_actual[i], + fittedpar[i]) + self.assertTrue(test_condition, msg) + + def testUncertainties(self): + """Test for validity of uncertainties in returned full-output + dictionary. This is a non-regression test for pull request #197""" + parameters_actual = [10.5, 2, 1000.0, 20., 15, 2001.0, 30.1, 16] + x = numpy.arange(10000.) + y = self.gauss(x, *parameters_actual) + parameters_estimate = [0.0, 1.0, 900.0, 25., 10., 1500., 20., 2.0] + + # test that uncertainties are not 0. + fittedpar, cov, infodict = self.instance(self.gauss, x, y, parameters_estimate, + full_output=True) + uncertainties = infodict["uncertainties"] + self.assertEqual(len(uncertainties), len(parameters_actual)) + self.assertEqual(len(uncertainties), len(fittedpar)) + for uncertainty in uncertainties: + self.assertNotAlmostEqual(uncertainty, 0.) + + # set constraint FIXED for half the parameters. + # This should cause leastsq to return 100% uncertainty. + parameters_estimate = [10.6, 2.1, 1000.1, 20.1, 15.1, 2001.1, 30.2, 16.1] + CFIXED = 3 + CFREE = 0 + constraints = [] + for i in range(len(parameters_estimate)): + if i % 2: + constraints.append([CFIXED, 0, 0]) + else: + constraints.append([CFREE, 0, 0]) + fittedpar, cov, infodict = self.instance(self.gauss, x, y, parameters_estimate, + constraints=constraints, + full_output=True) + uncertainties = infodict["uncertainties"] + for i in range(len(parameters_estimate)): + if i % 2: + # test that all FIXED parameters have 100% uncertainty + self.assertAlmostEqual(uncertainties[i], + parameters_estimate[i]) diff --git a/src/silx/math/fit/test/test_fitmanager.py b/src/silx/math/fit/test/test_fitmanager.py new file mode 100644 index 0000000..4ab56a5 --- /dev/null +++ b/src/silx/math/fit/test/test_fitmanager.py @@ -0,0 +1,498 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Tests for fitmanager module +""" + +import unittest +import numpy +import os.path + +from silx.math.fit import fitmanager +from silx.math.fit import fittheories +from silx.math.fit import bgtheories +from silx.math.fit.fittheory import FitTheory +from silx.math.fit.functions import sum_gauss, sum_stepdown, sum_stepup + +from silx.utils.testutils import ParametricTestCase +from silx.test.utils import temp_dir + +custom_function_definition = """ +import copy +from silx.math.fit.fittheory import FitTheory + +CONFIG = {'d': 1.} + +def myfun(x, a, b, c): + "Model function" + return (a * x**2 + b * x + c) / CONFIG['d'] + +def myesti(x, y): + "Initial parameters for iterative fit (a, b, c) = (1, 1, 1)" + return (1., 1., 1.), ((0, 0, 0), (0, 0, 0), (0, 0, 0)) + +def myconfig(d=1., **kw): + "This function can modify CONFIG" + CONFIG["d"] = d + return CONFIG + +def myderiv(x, parameters, index): + "Custom derivative (does not work, causes singular matrix)" + pars_plus = copy.copy(parameters) + pars_plus[index] *= 1.0001 + + pars_minus = parameters + pars_minus[index] *= copy.copy(0.9999) + + delta_fun = myfun(x, *pars_plus) - myfun(x, *pars_minus) + delta_par = parameters[index] * 0.0001 * 2 + + return delta_fun / delta_par + +THEORY = { + 'my fit theory': + FitTheory(function=myfun, + parameters=('A', 'B', 'C'), + estimate=myesti, + configure=myconfig, + derivative=myderiv) +} + +""" + +old_custom_function_definition = """ +CONFIG = {'d': 1.0} + +def myfun(x, a, b, c): + "Model function" + return (a * x**2 + b * x + c) / CONFIG['d'] + +def myesti(x, y, bg, xscalinq, yscaling): + "Initial parameters for iterative fit (a, b, c) = (1, 1, 1)" + return (1., 1., 1.), ((0, 0, 0), (0, 0, 0), (0, 0, 0)) + +def myconfig(**kw): + "Update or complete CONFIG dictionary" + for key in kw: + CONFIG[key] = kw[key] + return CONFIG + +THEORY = ['my fit theory'] +PARAMETERS = [('A', 'B', 'C')] +FUNCTION = [myfun] +ESTIMATE = [myesti] +CONFIGURE = [myconfig] + +""" + + +def _order_of_magnitude(x): + return numpy.log10(x).round() + + +class TestFitmanager(ParametricTestCase): + """ + Unit tests of multi-peak functions. + """ + def setUp(self): + pass + + def tearDown(self): + pass + + def testFitManager(self): + """Test fit manager on synthetic data using a gaussian function + and a linear background""" + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(1000).astype(numpy.float64) + + p = [1000, 100., 250, + 255, 650., 45, + 1500, 800.5, 95] + linear_bg = 2.65 * x + 13 + y = linear_bg + sum_gauss(x, *p) + + y_with_nans = numpy.array(y) + y_with_nans[::10] = numpy.nan + + x_with_nans = numpy.array(x) + x_with_nans[5::15] = numpy.nan + + tests = { + 'all finite': (x, y), + 'y with NaNs': (x, y_with_nans), + 'x with NaNs': (x_with_nans, y), + } + + for name, (xdata, ydata) in tests.items(): + with self.subTest(name=name): + # Fitting + fit = fitmanager.FitManager() + fit.setdata(x=xdata, y=ydata) + fit.loadtheories(fittheories) + # Use one of the default fit functions + fit.settheory('Gaussians') + fit.setbackground('Linear') + fit.estimate() + fit.runfit() + + # fit.fit_results[] + + # first 2 parameters are related to the linear background + self.assertEqual(fit.fit_results[0]["name"], "Constant") + self.assertAlmostEqual(fit.fit_results[0]["fitresult"], 13) + self.assertEqual(fit.fit_results[1]["name"], "Slope") + self.assertAlmostEqual(fit.fit_results[1]["fitresult"], 2.65) + + for i, param in enumerate(fit.fit_results[2:]): + param_number = i // 3 + 1 + if i % 3 == 0: + self.assertEqual(param["name"], + "Height%d" % param_number) + elif i % 3 == 1: + self.assertEqual(param["name"], + "Position%d" % param_number) + elif i % 3 == 2: + self.assertEqual(param["name"], + "FWHM%d" % param_number) + + self.assertAlmostEqual(param["fitresult"], + p[i]) + self.assertAlmostEqual(_order_of_magnitude(param["estimation"]), + _order_of_magnitude(p[i])) + + def testLoadCustomFitFunction(self): + """Test FitManager using a custom fit function defined in an external + file and imported with FitManager.loadtheories""" + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(100).astype(numpy.float64) + + # a, b, c are the fit parameters + # d is a known scaling parameter that is set using configure() + a, b, c, d = 1.5, 2.5, 3.5, 4.5 + y = (a * x**2 + b * x + c) / d + + # Fitting + fit = fitmanager.FitManager() + fit.setdata(x=x, y=y) + + # Create a temporary function definition file, and import it + with temp_dir() as tmpDir: + tmpfile = os.path.join(tmpDir, 'customfun.py') + # custom_function_definition + fd = open(tmpfile, "w") + fd.write(custom_function_definition) + fd.close() + fit.loadtheories(tmpfile) + tmpfile_pyc = os.path.join(tmpDir, 'customfun.pyc') + if os.path.exists(tmpfile_pyc): + os.unlink(tmpfile_pyc) + os.unlink(tmpfile) + + fit.settheory('my fit theory') + # Test configure + fit.configure(d=4.5) + fit.estimate() + fit.runfit() + + self.assertEqual(fit.fit_results[0]["name"], + "A1") + self.assertAlmostEqual(fit.fit_results[0]["fitresult"], + 1.5) + self.assertEqual(fit.fit_results[1]["name"], + "B1") + self.assertAlmostEqual(fit.fit_results[1]["fitresult"], + 2.5) + self.assertEqual(fit.fit_results[2]["name"], + "C1") + self.assertAlmostEqual(fit.fit_results[2]["fitresult"], + 3.5) + + def testLoadOldCustomFitFunction(self): + """Test FitManager using a custom fit function defined in an external + file and imported with FitManager.loadtheories (legacy PyMca format)""" + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(100).astype(numpy.float64) + + # a, b, c are the fit parameters + # d is a known scaling parameter that is set using configure() + a, b, c, d = 1.5, 2.5, 3.5, 4.5 + y = (a * x**2 + b * x + c) / d + + # Fitting + fit = fitmanager.FitManager() + fit.setdata(x=x, y=y) + + # Create a temporary function definition file, and import it + with temp_dir() as tmpDir: + tmpfile = os.path.join(tmpDir, 'oldcustomfun.py') + # custom_function_definition + fd = open(tmpfile, "w") + fd.write(old_custom_function_definition) + fd.close() + fit.loadtheories(tmpfile) + tmpfile_pyc = os.path.join(tmpDir, 'oldcustomfun.pyc') + if os.path.exists(tmpfile_pyc): + os.unlink(tmpfile_pyc) + os.unlink(tmpfile) + + fit.settheory('my fit theory') + fit.configure(d=4.5) + fit.estimate() + fit.runfit() + + self.assertEqual(fit.fit_results[0]["name"], + "A1") + self.assertAlmostEqual(fit.fit_results[0]["fitresult"], + 1.5) + self.assertEqual(fit.fit_results[1]["name"], + "B1") + self.assertAlmostEqual(fit.fit_results[1]["fitresult"], + 2.5) + self.assertEqual(fit.fit_results[2]["name"], + "C1") + self.assertAlmostEqual(fit.fit_results[2]["fitresult"], + 3.5) + + def testAddTheory(self, estimate=True): + """Test FitManager using a custom fit function imported with + FitManager.addtheory""" + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(100).astype(numpy.float64) + + # a, b, c are the fit parameters + # d is a known scaling parameter that is set using configure() + a, b, c, d = -3.14, 1234.5, 10000, 4.5 + y = (a * x**2 + b * x + c) / d + + # Fitting + fit = fitmanager.FitManager() + fit.setdata(x=x, y=y) + + # Define and add the fit theory + CONFIG = {'d': 1.} + + def myfun(x_, a_, b_, c_): + """"Model function""" + return (a_ * x_**2 + b_ * x_ + c_) / CONFIG['d'] + + def myesti(x_, y_): + """"Initial parameters for iterative fit: + (a, b, c) = (1, 1, 1) + Constraints all set to 0 (FREE)""" + return (1., 1., 1.), ((0, 0, 0), (0, 0, 0), (0, 0, 0)) + + def myconfig(d_=1., **kw): + """This function can modify CONFIG""" + CONFIG["d"] = d_ + return CONFIG + + def myderiv(x_, parameters, index): + """Custom derivative""" + pars_plus = numpy.array(parameters, copy=True) + pars_plus[index] *= 1.001 + + pars_minus = numpy.array(parameters, copy=True) + pars_minus[index] *= 0.999 + + delta_fun = myfun(x_, *pars_plus) - myfun(x_, *pars_minus) + delta_par = parameters[index] * 0.001 * 2 + + return delta_fun / delta_par + + fit.addtheory("polynomial", + FitTheory(function=myfun, + parameters=["A", "B", "C"], + estimate=myesti if estimate else None, + configure=myconfig, + derivative=myderiv)) + + fit.settheory('polynomial') + fit.configure(d_=4.5) + fit.estimate() + params1, sigmas, infodict = fit.runfit() + + self.assertEqual(fit.fit_results[0]["name"], + "A1") + self.assertAlmostEqual(fit.fit_results[0]["fitresult"], + -3.14) + self.assertEqual(fit.fit_results[1]["name"], + "B1") + # params1[1] is the same as fit.fit_results[1]["fitresult"] + self.assertAlmostEqual(params1[1], + 1234.5) + self.assertEqual(fit.fit_results[2]["name"], + "C1") + self.assertAlmostEqual(params1[2], + 10000) + + # change configuration scaling factor and check that the fit returns + # different values + fit.configure(d_=5.) + fit.estimate() + params2, sigmas, infodict = fit.runfit() + for p1, p2 in zip(params1, params2): + self.assertFalse(numpy.array_equal(p1, p2), + "Fit parameters are equal even though the " + + "configuration has been changed") + + def testNoEstimate(self): + """Ensure that the in the absence of the estimation function, + the default estimation function :meth:`FitTheory.default_estimate` + is used.""" + self.testAddTheory(estimate=False) + + def testStep(self): + """Test fit manager on a step function with a more complex estimate + function than the gaussian (convolution filter)""" + for theory_name, theory_fun in (('Step Down', sum_stepdown), + ('Step Up', sum_stepup)): + # Create synthetic data with a sum of gaussian functions + x = numpy.arange(1000).astype(numpy.float64) + + # ('Height', 'Position', 'FWHM') + p = [1000, 439, 250] + + constantbg = 13 + y = theory_fun(x, *p) + constantbg + + # Fitting + fit = fitmanager.FitManager() + fit.setdata(x=x, y=y) + fit.loadtheories(fittheories) + fit.settheory(theory_name) + fit.setbackground('Constant') + + fit.estimate() + + params, sigmas, infodict = fit.runfit() + + # first parameter is the constant background + self.assertAlmostEqual(params[0], 13, places=5) + for i, param in enumerate(params[1:]): + self.assertAlmostEqual(param, p[i], places=5) + self.assertAlmostEqual(_order_of_magnitude(fit.fit_results[i+1]["estimation"]), + _order_of_magnitude(p[i])) + + +def quadratic(x, a, b, c): + return a * x**2 + b * x + c + + +def cubic(x, a, b, c, d): + return a * x**3 + b * x**2 + c * x + d + + +class TestPolynomials(unittest.TestCase): + """Test polynomial fit theories and fit background""" + def setUp(self): + self.x = numpy.arange(100).astype(numpy.float64) + + def testQuadraticBg(self): + gaussian_params = [100, 45, 8] + poly_params = [0.05, -2, 3] + p = numpy.poly1d(poly_params) + + y = p(self.x) + sum_gauss(self.x, *gaussian_params) + + fm = fitmanager.FitManager(self.x, y) + fm.loadbgtheories(bgtheories) + fm.loadtheories(fittheories) + fm.settheory("Gaussians") + fm.setbackground("Degree 2 Polynomial") + esti_params = fm.estimate() + fit_params = fm.runfit()[0] + + for p, pfit in zip(poly_params + gaussian_params, fit_params): + self.assertAlmostEqual(p, + pfit) + + def testCubicBg(self): + gaussian_params = [1000, 45, 8] + poly_params = [0.0005, -0.05, 3, -4] + p = numpy.poly1d(poly_params) + + y = p(self.x) + sum_gauss(self.x, *gaussian_params) + + fm = fitmanager.FitManager(self.x, y) + fm.loadtheories(fittheories) + fm.settheory("Gaussians") + fm.setbackground("Degree 3 Polynomial") + esti_params = fm.estimate() + fit_params = fm.runfit()[0] + + for p, pfit in zip(poly_params + gaussian_params, fit_params): + self.assertAlmostEqual(p, + pfit) + + def testQuarticcBg(self): + gaussian_params = [10000, 69, 25] + poly_params = [5e-10, 0.0005, 0.005, 2, 4] + p = numpy.poly1d(poly_params) + + y = p(self.x) + sum_gauss(self.x, *gaussian_params) + + fm = fitmanager.FitManager(self.x, y) + fm.loadtheories(fittheories) + fm.settheory("Gaussians") + fm.setbackground("Degree 4 Polynomial") + esti_params = fm.estimate() + fit_params = fm.runfit()[0] + + for p, pfit in zip(poly_params + gaussian_params, fit_params): + self.assertAlmostEqual(p, + pfit, + places=5) + + def _testPoly(self, poly_params, theory, places=5): + p = numpy.poly1d(poly_params) + + y = p(self.x) + + fm = fitmanager.FitManager(self.x, y) + fm.loadbgtheories(bgtheories) + fm.loadtheories(fittheories) + fm.settheory(theory) + esti_params = fm.estimate() + fit_params = fm.runfit()[0] + + for p, pfit in zip(poly_params, fit_params): + self.assertAlmostEqual(p, pfit, places=places) + + def testQuadratic(self): + self._testPoly([0.05, -2, 3], + "Degree 2 Polynomial") + + def testCubic(self): + self._testPoly([0.0005, -0.05, 3, -4], + "Degree 3 Polynomial") + + def testQuartic(self): + self._testPoly([1, -2, 3, -4, -5], + "Degree 4 Polynomial") + + def testQuintic(self): + self._testPoly([1, -2, 3, -4, -5, 6], + "Degree 5 Polynomial", + places=4) diff --git a/src/silx/math/fit/test/test_functions.py b/src/silx/math/fit/test/test_functions.py new file mode 100644 index 0000000..7e3ff63 --- /dev/null +++ b/src/silx/math/fit/test/test_functions.py @@ -0,0 +1,259 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Tests for functions module +""" + +import unittest +import numpy +import math + +from silx.math.fit import functions + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "21/07/2016" + +class Test_functions(unittest.TestCase): + """ + Unit tests of multi-peak functions. + """ + def setUp(self): + self.x = numpy.arange(11) + + # height, center, sigma1, sigma2 + (h, c, s1, s2) = (7., 5., 3., 2.1) + self.g_params = { + "height": h, + "center": c, + #"sigma": s, + "fwhm1": 2 * math.sqrt(2 * math.log(2)) * s1, + "fwhm2": 2 * math.sqrt(2 * math.log(2)) * s2, + "area1": h * s1 * math.sqrt(2 * math.pi) + } + # result of `7 * scipy.signal.gaussian(11, 3)` + self.scipy_gaussian = numpy.array( + [1.74546546, 2.87778603, 4.24571462, 5.60516182, 6.62171628, + 7., 6.62171628, 5.60516182, 4.24571462, 2.87778603, + 1.74546546] + ) + + # result of: + # numpy.concatenate((7 * scipy.signal.gaussian(11, 3)[0:5], + # 7 * scipy.signal.gaussian(11, 2.1)[5:11])) + self.scipy_asym_gaussian = numpy.array( + [1.74546546, 2.87778603, 4.24571462, 5.60516182, 6.62171628, + 7., 6.24968751, 4.44773692, 2.52313452, 1.14093853, 0.41124877] + ) + + def tearDown(self): + pass + + def testGauss(self): + """Compare sum_gauss with scipy.signals.gaussian""" + y = functions.sum_gauss(self.x, + self.g_params["height"], + self.g_params["center"], + self.g_params["fwhm1"]) + + for i in range(11): + self.assertAlmostEqual(y[i], self.scipy_gaussian[i]) + + def testAGauss(self): + """Compare sum_agauss with scipy.signals.gaussian""" + y = functions.sum_agauss(self.x, + self.g_params["area1"], + self.g_params["center"], + self.g_params["fwhm1"]) + for i in range(11): + self.assertAlmostEqual(y[i], self.scipy_gaussian[i]) + + def testFastAGauss(self): + """Compare sum_fastagauss with scipy.signals.gaussian + Limit precision to 3 decimal places.""" + y = functions.sum_fastagauss(self.x, + self.g_params["area1"], + self.g_params["center"], + self.g_params["fwhm1"]) + for i in range(11): + self.assertAlmostEqual(y[i], self.scipy_gaussian[i], 3) + + + def testSplitGauss(self): + """Compare sum_splitgauss with scipy.signals.gaussian""" + y = functions.sum_splitgauss(self.x, + self.g_params["height"], + self.g_params["center"], + self.g_params["fwhm1"], + self.g_params["fwhm2"]) + for i in range(11): + self.assertAlmostEqual(y[i], self.scipy_asym_gaussian[i]) + + def testErf(self): + """Compare erf with math.erf""" + # scalars + self.assertAlmostEqual(functions.erf(0.14), math.erf(0.14), places=5) + self.assertAlmostEqual(functions.erf(0), math.erf(0), places=5) + self.assertAlmostEqual(functions.erf(-0.74), math.erf(-0.74), places=5) + + # lists + x = [-5, -2, -1.5, -0.6, 0, 0.1, 2, 3] + erfx = functions.erf(x) + for i in range(len(x)): + self.assertAlmostEqual(erfx[i], + math.erf(x[i]), + places=5) + + # ndarray + x = numpy.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) + erfx = functions.erf(x) + for i in range(x.shape[0]): + for j in range(x.shape[1]): + self.assertAlmostEqual(erfx[i, j], + math.erf(x[i, j]), + places=5) + + def testErfc(self): + """Compare erf with math.erf""" + # scalars + self.assertAlmostEqual(functions.erfc(0.14), math.erfc(0.14), places=5) + self.assertAlmostEqual(functions.erfc(0), math.erfc(0), places=5) + self.assertAlmostEqual(functions.erfc(-0.74), math.erfc(-0.74), places=5) + + # lists + x = [-5, -2, -1.5, -0.6, 0, 0.1, 2, 3] + erfcx = functions.erfc(x) + for i in range(len(x)): + self.assertAlmostEqual(erfcx[i], math.erfc(x[i]), places=5) + + # ndarray + x = numpy.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) + erfcx = functions.erfc(x) + for i in range(x.shape[0]): + for j in range(x.shape[1]): + self.assertAlmostEqual(erfcx[i, j], math.erfc(x[i, j]), places=5) + + def testAtanStepUp(self): + """Compare atan_stepup with math.atan + + atan_stepup(x, a, b, c) = a * (0.5 + (arctan((x - b) / c) / pi))""" + x0 = numpy.arange(100) / 6.33 + y0 = functions.atan_stepup(x0, 11.1, 22.2, 3.33) + + for x, y in zip(x0, y0): + self.assertAlmostEqual( + 11.1 * (0.5 + math.atan((x - 22.2) / 3.33) / math.pi), + y + ) + + def testStepUp(self): + """sanity check for step up: + + - derivative must be largest around the step center + - max value must be close to height parameter + + """ + x0 = numpy.arange(1000) + center = 444 + height = 1234 + fwhm = 210 + y0 = functions.sum_stepup(x0, height, center, fwhm) + + self.assertLess(max(y0), height) + self.assertAlmostEqual(max(y0), height, places=1) + self.assertAlmostEqual(min(y0), 0, places=1) + + deriv0 = _numerical_derivative(functions.sum_stepup, x0, [height, center, fwhm]) + + # Test center position within +- 1 sample of max derivative + index_max_deriv = numpy.argmax(deriv0) + self.assertLess(abs(index_max_deriv - center), + 1) + + def testStepDown(self): + """sanity check for step down: + + - absolute value of derivative must be largest around the step center + - max value must be close to height parameter + + """ + x0 = numpy.arange(1000) + center = 444 + height = 1234 + fwhm = 210 + y0 = functions.sum_stepdown(x0, height, center, fwhm) + + self.assertLess(max(y0), height) + self.assertAlmostEqual(max(y0), height, places=1) + self.assertAlmostEqual(min(y0), 0, places=1) + + deriv0 = _numerical_derivative(functions.sum_stepdown, x0, [height, center, fwhm]) + + # Test center position within +- 1 sample of max derivative + index_min_deriv = numpy.argmax(-deriv0) + self.assertLess(abs(index_min_deriv - center), + 1) + + def testSlit(self): + """sanity check for slit: + + - absolute value of derivative must be largest around the step center + - max value must be close to height parameter + + """ + x0 = numpy.arange(1000) + center = 444 + height = 1234 + fwhm = 210 + beamfwhm = 30 + y0 = functions.sum_slit(x0, height, center, fwhm, beamfwhm) + + self.assertAlmostEqual(max(y0), height, places=1) + self.assertAlmostEqual(min(y0), 0, places=1) + + deriv0 = _numerical_derivative(functions.sum_slit, x0, [height, center, fwhm, beamfwhm]) + + # Test step up center position (center - fwhm/2) within +- 1 sample of max derivative + index_max_deriv = numpy.argmax(deriv0) + self.assertLess(abs(index_max_deriv - (center - fwhm/2)), + 1) + # Test step down center position (center + fwhm/2) within +- 1 sample of min derivative + index_min_deriv = numpy.argmin(deriv0) + self.assertLess(abs(index_min_deriv - (center + fwhm/2)), + 1) + + +def _numerical_derivative(f, x, params=[], delta_factor=0.0001): + """Compute the numerical derivative of ``f`` for all values of ``x``. + + :param f: function + :param x: Array of evenly spaced abscissa values + :param params: list of additional parameters + :return: Array of derivative values + """ + deltax = (x[1] - x[0]) * delta_factor + y_plus = f(x + deltax, *params) + y_minus = f(x - deltax, *params) + + return (y_plus - y_minus) / (2 * deltax) diff --git a/src/silx/math/fit/test/test_peaks.py b/src/silx/math/fit/test/test_peaks.py new file mode 100644 index 0000000..495c70d --- /dev/null +++ b/src/silx/math/fit/test/test_peaks.py @@ -0,0 +1,132 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +Tests for peaks module +""" + +import unittest +import numpy +import math + +from silx.math.fit import functions +from silx.math.fit import peaks + +class Test_peak_search(unittest.TestCase): + """ + Unit tests of peak_search on various types of multi-peak functions. + """ + def setUp(self): + self.x = numpy.arange(5000) + # (height1, center1, fwhm1, ...) + self.h_c_fwhm = (50, 500, 100, + 50, 600, 80, + 20, 2000, 100, + 50, 2250, 110, + 40, 3000, 99, + 23, 4980, 80) + # (height1, center1, fwhm1, eta1 ...) + self.h_c_fwhm_eta = (50, 500, 100, 0.4, + 50, 600, 80, 0.5, + 20, 2000, 100, 0.6, + 50, 2250, 110, 0.7, + 40, 3000, 99, 0.8, + 23, 4980, 80, 0.3,) + # (height1, center1, fwhm11, fwhm21, ...) + self.h_c_fwhm_fwhm = (50, 500, 100, 85, + 50, 600, 80, 110, + 20, 2000, 100, 100, + 50, 2250, 110, 99, + 40, 3000, 99, 110, + 23, 4980, 80, 80,) + # (height1, center1, fwhm11, fwhm21, eta1 ...) + self.h_c_fwhm_fwhm_eta = (50, 500, 100, 85, 0.4, + 50, 600, 80, 110, 0.5, + 20, 2000, 100, 100, 0.6, + 50, 2250, 110, 99, 0.7, + 40, 3000, 99, 110, 0.8, + 23, 4980, 80, 80, 0.3,) + # (area1, center1, fwhm1, ...) + self.a_c_fwhm = (2550, 500, 100, + 2000, 600, 80, + 500, 2000, 100, + 4000, 2250, 110, + 2300, 3000, 99, + 3333, 4980, 80) + # (area1, center1, fwhm1, eta1 ...) + self.a_c_fwhm_eta = (500, 500, 100, 0.4, + 500, 600, 80, 0.5, + 200, 2000, 100, 0.6, + 500, 2250, 110, 0.7, + 400, 3000, 99, 0.8, + 230, 4980, 80, 0.3,) + # (area, position, fwhm, st_area_r, st_slope_r, lt_area_r, lt_slope_r, step_height_r) + self.hypermet_params = (1000, 500, 200, 0.2, 100, 0.3, 100, 0.05, + 1000, 1000, 200, 0.2, 100, 0.3, 100, 0.05, + 1000, 2000, 200, 0.2, 100, 0.3, 100, 0.05, + 1000, 2350, 200, 0.2, 100, 0.3, 100, 0.05, + 1000, 3000, 200, 0.2, 100, 0.3, 100, 0.05, + 1000, 4900, 200, 0.2, 100, 0.3, 100, 0.05,) + + + def tearDown(self): + pass + + def get_peaks(self, function, params): + """ + + :param function: Multi-peak function + :param params: Parameter for this function + :return: list of (peak, relevance) tuples + """ + y = function(self.x, *params) + return peaks.peak_search(y=y, fwhm=100, relevance_info=True) + + def testPeakSearch_various_functions(self): + """Run peak search on a variety of synthetic functions, and + check that result falls within +-25 samples of the actual peak + (reasonable delta considering a fwhm of ~100 samples) and effects + of overlapping peaks).""" + f_p = ((functions.sum_gauss, self.h_c_fwhm ), + (functions.sum_lorentz, self.h_c_fwhm), + (functions.sum_pvoigt, self.h_c_fwhm_eta), + (functions.sum_splitgauss, self.h_c_fwhm_fwhm), + (functions.sum_splitlorentz, self.h_c_fwhm_fwhm), + (functions.sum_splitpvoigt, self.h_c_fwhm_fwhm_eta), + (functions.sum_agauss, self.a_c_fwhm), + (functions.sum_fastagauss, self.a_c_fwhm), + (functions.sum_alorentz, self.a_c_fwhm), + (functions.sum_apvoigt, self.a_c_fwhm_eta), + (functions.sum_ahypermet, self.hypermet_params), + (functions.sum_fastahypermet, self.hypermet_params),) + + for function, params in f_p: + peaks = self.get_peaks(function, params) + + self.assertEqual(len(peaks), 6, + "Wrong number of peaks detected") + + for i in range(6): + theoretical_peak_index = params[i*(len(params)//6) + 1] + found_peak_index = peaks[i][0] + self.assertLess(abs(found_peak_index - theoretical_peak_index), 25) |