aptapy 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aptapy/__init__.py +44 -0
- aptapy/_version.py +1 -0
- aptapy/modeling.py +515 -0
- aptapy/plotting.py +486 -0
- aptapy/py.typed +0 -0
- aptapy/typing_.py +23 -0
- aptapy-0.1.1.dist-info/METADATA +702 -0
- aptapy-0.1.1.dist-info/RECORD +10 -0
- aptapy-0.1.1.dist-info/WHEEL +4 -0
- aptapy-0.1.1.dist-info/licenses/LICENSE +674 -0
aptapy/__init__.py
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# Copyright (C) 2025 Luca Baldini (luca.baldini@pi.infn.it)
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
15
|
+
|
16
|
+
import pathlib
|
17
|
+
import subprocess
|
18
|
+
|
19
|
+
from ._version import __version__ as __base_version__
|
20
|
+
|
21
|
+
|
22
|
+
def _git_suffix() -> str:
|
23
|
+
"""If we are in a git repo, we want to add the necessary information to the
|
24
|
+
version string.
|
25
|
+
|
26
|
+
This will return something along the lines of ``+gf0f18e6.dirty``.
|
27
|
+
"""
|
28
|
+
# pylint: disable=broad-except
|
29
|
+
kwargs = dict(cwd=pathlib.Path(__file__).parent, stderr=subprocess.DEVNULL)
|
30
|
+
try:
|
31
|
+
# Retrieve the git short sha to be appended to the base version string.
|
32
|
+
args = ["git", "rev-parse", "--short", "HEAD"]
|
33
|
+
sha = subprocess.check_output(args, **kwargs).decode().strip()
|
34
|
+
suffix = f"+g{sha}"
|
35
|
+
# If we have uncommitted changes, append a `.dirty` to the version suffix.
|
36
|
+
args = ["git", "diff", "--quiet"]
|
37
|
+
if subprocess.call(args, stdout=subprocess.DEVNULL, **kwargs) != 0:
|
38
|
+
suffix = f"{suffix}.dirty"
|
39
|
+
return suffix
|
40
|
+
except Exception:
|
41
|
+
return ""
|
42
|
+
|
43
|
+
|
44
|
+
__version__ = f"{__base_version__}{_git_suffix()}"
|
aptapy/_version.py
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = "0.1.1"
|
aptapy/modeling.py
ADDED
@@ -0,0 +1,515 @@
|
|
1
|
+
# Copyright (C) 2025 Luca Baldini (luca.baldini@pi.infn.it)
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
15
|
+
|
16
|
+
"""Modeling facilities.
|
17
|
+
"""
|
18
|
+
|
19
|
+
import enum
|
20
|
+
import functools
|
21
|
+
import inspect
|
22
|
+
from abc import ABC, abstractmethod
|
23
|
+
from dataclasses import dataclass
|
24
|
+
from numbers import Number
|
25
|
+
from typing import Iterator, Tuple
|
26
|
+
|
27
|
+
import matplotlib.pyplot as plt
|
28
|
+
import numpy as np
|
29
|
+
import uncertainties
|
30
|
+
from scipy.optimize import curve_fit
|
31
|
+
|
32
|
+
from aptapy.typing_ import ArrayLike
|
33
|
+
|
34
|
+
|
35
|
+
class Format(str, enum.Enum):
|
36
|
+
|
37
|
+
"""Small enum class to control string formatting.
|
38
|
+
|
39
|
+
This is leveraging the custom formatting of the uncertainties package, where
|
40
|
+
a trailing 'P' means "pretty print" and a trailing "L" means LaTeX.
|
41
|
+
"""
|
42
|
+
|
43
|
+
PRETTY = 'P'
|
44
|
+
LATEX = 'L'
|
45
|
+
|
46
|
+
|
47
|
+
@dataclass
|
48
|
+
class FitParameter:
|
49
|
+
|
50
|
+
"""Small class describing a fit parameter.
|
51
|
+
"""
|
52
|
+
|
53
|
+
value: float
|
54
|
+
_name: str = None
|
55
|
+
error: float = None
|
56
|
+
_frozen: bool = False
|
57
|
+
minimum: float = -np.inf
|
58
|
+
maximum: float = np.inf
|
59
|
+
|
60
|
+
@property
|
61
|
+
def name(self) -> str:
|
62
|
+
"""Return the parameter name.
|
63
|
+
|
64
|
+
We are wrapping this into a property because, arguably, the parameter name is
|
65
|
+
the only thing we never, ever want to change after the fact.
|
66
|
+
"""
|
67
|
+
return self._name
|
68
|
+
|
69
|
+
@property
|
70
|
+
def frozen(self) -> bool:
|
71
|
+
"""Return True if the parameter is frozen.
|
72
|
+
|
73
|
+
We are wrapping this into a property because we interact with this member
|
74
|
+
via the freeze() and thaw() methods.
|
75
|
+
"""
|
76
|
+
return self._frozen
|
77
|
+
|
78
|
+
def is_bound(self) -> bool:
|
79
|
+
"""Return True if the parameter is bounded.
|
80
|
+
"""
|
81
|
+
return not np.isinf(self.minimum) or not np.isinf(self.maximum)
|
82
|
+
|
83
|
+
def copy(self, name: str) -> 'FitParameter':
|
84
|
+
"""Create a copy of the parameter object with a new name.
|
85
|
+
|
86
|
+
This is necessary because we define the fit parameters of the actual model as
|
87
|
+
class variables holding the default value, and each instance gets their own
|
88
|
+
copy of the parameter, where the name is automatically inferred.
|
89
|
+
|
90
|
+
Note that, in addition to the name being passed as an argument, we only carry
|
91
|
+
over the value and bounds of the original fit parameter: the new object is
|
92
|
+
created with error = None and _frozen = False.
|
93
|
+
|
94
|
+
Arguments
|
95
|
+
---------
|
96
|
+
name : str
|
97
|
+
The name for the new FitParameter object.
|
98
|
+
"""
|
99
|
+
return self.__class__(self.value, name, minimum=self.minimum, maximum=self.maximum)
|
100
|
+
|
101
|
+
def set(self, value: float, error: float = None) -> None:
|
102
|
+
"""Set the parameter value and error.
|
103
|
+
|
104
|
+
Arguments
|
105
|
+
---------
|
106
|
+
value : float
|
107
|
+
The new value for the parameter.
|
108
|
+
|
109
|
+
error : float, optional
|
110
|
+
The new error for the parameter (default None).
|
111
|
+
"""
|
112
|
+
self.value = value
|
113
|
+
self.error = error
|
114
|
+
|
115
|
+
def freeze(self, value: float) -> None:
|
116
|
+
"""Freeze the fit parameter to a given value.
|
117
|
+
|
118
|
+
Note that the error is set to None.
|
119
|
+
|
120
|
+
Arguments
|
121
|
+
---------
|
122
|
+
value : float
|
123
|
+
The new value for the parameter.
|
124
|
+
"""
|
125
|
+
self.set(value)
|
126
|
+
self._frozen = True
|
127
|
+
|
128
|
+
def thaw(self) -> None:
|
129
|
+
"""Un-freeze the fit parameter.
|
130
|
+
"""
|
131
|
+
self._frozen = False
|
132
|
+
|
133
|
+
def ufloat(self) -> uncertainties.ufloat:
|
134
|
+
"""Return the parameter value and error as a ufloat object.
|
135
|
+
"""
|
136
|
+
return uncertainties.ufloat(self.value, self.error)
|
137
|
+
|
138
|
+
def __format__(self, spec: str) -> str:
|
139
|
+
"""String formatting.
|
140
|
+
"""
|
141
|
+
# Keep in mind Python passes an empty string explicitly when you call
|
142
|
+
# f'{parameter}', so we can't really assign a default value to spec.
|
143
|
+
if self.error is not None:
|
144
|
+
param = format(self.ufloat(), spec)
|
145
|
+
if spec.endswith(Format.LATEX):
|
146
|
+
param = f'${param}$'
|
147
|
+
else:
|
148
|
+
spec = spec.rstrip(Format.PRETTY).rstrip(Format.LATEX)
|
149
|
+
param = format(self.value, spec)
|
150
|
+
text = f'{self._name.title()}: {param}'
|
151
|
+
info = []
|
152
|
+
if self._frozen:
|
153
|
+
info.append('frozen')
|
154
|
+
if not np.isinf(self.minimum):
|
155
|
+
info.append(f'min={self.minimum}')
|
156
|
+
if not np.isinf(self.maximum):
|
157
|
+
info.append(f'max={self.maximum}')
|
158
|
+
if info:
|
159
|
+
text = f'{text} ({", ".join(info)})'
|
160
|
+
return text
|
161
|
+
|
162
|
+
def __str__(self) -> str:
|
163
|
+
"""String formatting.
|
164
|
+
|
165
|
+
This is meant to provide a more human-readable version of the parameter formatting
|
166
|
+
than the default ``__repr__`` implementation from the dataclass decorator, and it
|
167
|
+
is what is used in the actual printout of the fit parameters from a fit.
|
168
|
+
"""
|
169
|
+
return format(self, Format.PRETTY)
|
170
|
+
|
171
|
+
|
172
|
+
@dataclass
|
173
|
+
class FitStatus:
|
174
|
+
|
175
|
+
"""Small dataclass to hold the fit status.
|
176
|
+
"""
|
177
|
+
|
178
|
+
chisquare: float = None
|
179
|
+
dof: int = None
|
180
|
+
# pvalue: float = None
|
181
|
+
fit_range: Tuple[float, float] = None
|
182
|
+
|
183
|
+
def reset(self) -> None:
|
184
|
+
"""Reset the fit status.
|
185
|
+
"""
|
186
|
+
self.chisquare = None
|
187
|
+
self.dof = None
|
188
|
+
self.fit_range = None
|
189
|
+
|
190
|
+
def __format__(self, spec: str) -> str:
|
191
|
+
"""String formatting.
|
192
|
+
"""
|
193
|
+
if self.chisquare is None:
|
194
|
+
return 'N/A'
|
195
|
+
if spec.endswith(Format.LATEX):
|
196
|
+
return f'$\\chi^2$ = {self.chisquare:.2f} / {self.dof} dof'
|
197
|
+
if spec.endswith(Format.PRETTY):
|
198
|
+
return f'χ² = {self.chisquare:.2f} / {self.dof} dof'
|
199
|
+
return f'chisquare = {self.chisquare:.2f} / {self.dof} dof'
|
200
|
+
|
201
|
+
def __str__(self) -> str:
|
202
|
+
"""String formatting.
|
203
|
+
"""
|
204
|
+
return format(self, Format.PRETTY)
|
205
|
+
|
206
|
+
|
207
|
+
class AbstractFitModel(ABC):
|
208
|
+
|
209
|
+
"""Abstract base class for a fit model.
|
210
|
+
"""
|
211
|
+
|
212
|
+
def __init__(self) -> None:
|
213
|
+
"""Constructor.
|
214
|
+
|
215
|
+
Here we loop over the FitParameter objects defined at the class level, and
|
216
|
+
create copies that are attached to the instance, so that the latter has its
|
217
|
+
own state.
|
218
|
+
"""
|
219
|
+
self._parameters = []
|
220
|
+
for name, value in self.__class__.__dict__.items():
|
221
|
+
if isinstance(value, FitParameter):
|
222
|
+
parameter = value.copy(name)
|
223
|
+
# Note we also set one instance attribute for each parameter so
|
224
|
+
# that we can use the notation model.parameter
|
225
|
+
setattr(self, name, parameter)
|
226
|
+
self._parameters.append(parameter)
|
227
|
+
# Fit status object holding all the additional information from the fit.
|
228
|
+
self.status = FitStatus()
|
229
|
+
|
230
|
+
def name(self) -> str:
|
231
|
+
"""Return the model name.
|
232
|
+
"""
|
233
|
+
return self.__class__.__name__
|
234
|
+
|
235
|
+
def __len__(self) -> int:
|
236
|
+
"""Overloaded method.
|
237
|
+
"""
|
238
|
+
return len(self._parameters)
|
239
|
+
|
240
|
+
def __iter__(self) -> Iterator[FitParameter]:
|
241
|
+
"""Iteration protocol.
|
242
|
+
"""
|
243
|
+
return iter(self._parameters)
|
244
|
+
|
245
|
+
def parameter_values(self) -> Tuple[float]:
|
246
|
+
"""Return the current parameter values.
|
247
|
+
"""
|
248
|
+
return tuple(parameter.value for parameter in self)
|
249
|
+
|
250
|
+
def free_parameters(self) -> Tuple[FitParameter]:
|
251
|
+
"""Return the list of free parameters.
|
252
|
+
"""
|
253
|
+
return tuple(parameter for parameter in self if not parameter.frozen)
|
254
|
+
|
255
|
+
def free_parameter_values(self) -> Tuple[float]:
|
256
|
+
"""Return the current parameter values.
|
257
|
+
"""
|
258
|
+
return tuple(parameter.value for parameter in self.free_parameters())
|
259
|
+
|
260
|
+
@staticmethod
|
261
|
+
@abstractmethod
|
262
|
+
def evaluate(x: ArrayLike, *parameter_values: Tuple[float]) -> ArrayLike:
|
263
|
+
"""Evaluate the model at a given value (or set of values) of the independent variable,
|
264
|
+
for a given set of model parameters.
|
265
|
+
|
266
|
+
Arguments
|
267
|
+
---------
|
268
|
+
x : array_like
|
269
|
+
The value(s) of the independent variable.
|
270
|
+
|
271
|
+
params : tuple of float
|
272
|
+
The value of the model parameters.
|
273
|
+
"""
|
274
|
+
|
275
|
+
def __call__(self, x: ArrayLike) -> ArrayLike:
|
276
|
+
"""Evaluate the model at the current value of the parameters.
|
277
|
+
"""
|
278
|
+
return self.evaluate(x, *self.parameter_values())
|
279
|
+
|
280
|
+
def set_parameters(self, *values: float) -> None:
|
281
|
+
"""Set the values for all the parameters.
|
282
|
+
"""
|
283
|
+
for parameter, value in zip(self, values):
|
284
|
+
parameter.value = value
|
285
|
+
|
286
|
+
def init_parameters(self, x: ArrayLike, y: ArrayLike, sigma: ArrayLike) -> None:
|
287
|
+
"""Optional: override in subclasses if needed.
|
288
|
+
"""
|
289
|
+
# pylint: disable=unused-argument
|
290
|
+
return
|
291
|
+
|
292
|
+
def _update_parameters(self, popt: np.ndarray, pcov: np.ndarray) -> None:
|
293
|
+
"""Update the model parameters based on the output of the ``curve_fit()`` call.
|
294
|
+
"""
|
295
|
+
for parameter, value, error in zip(self.free_parameters(), popt, np.sqrt(pcov.diagonal())):
|
296
|
+
parameter.value = value
|
297
|
+
parameter.error = error
|
298
|
+
|
299
|
+
def bounds(self) -> Tuple[ArrayLike, ArrayLike]:
|
300
|
+
"""Return the bounds on the fit parameters in a form that can be use by the
|
301
|
+
fitting method.
|
302
|
+
"""
|
303
|
+
free_parameters = self.free_parameters()
|
304
|
+
return (tuple(parameter.minimum for parameter in free_parameters),
|
305
|
+
tuple(parameter.maximum for parameter in free_parameters))
|
306
|
+
|
307
|
+
def calculate_chisqure(self, xdata: np.ndarray, ydata: np.ndarray, sigma) -> float:
|
308
|
+
"""Calculate the chisquare of the fit to some input data with the current
|
309
|
+
model parameters.
|
310
|
+
"""
|
311
|
+
return float((((ydata - self(xdata)) / sigma)**2.).sum())
|
312
|
+
|
313
|
+
@staticmethod
|
314
|
+
def freeze(model_function, **constraints):
|
315
|
+
"""Freeze a subset of the model parameters.
|
316
|
+
"""
|
317
|
+
if not constraints:
|
318
|
+
return model_function
|
319
|
+
|
320
|
+
# Cache a couple of constant to save on line length later.
|
321
|
+
positional_only = inspect.Parameter.POSITIONAL_ONLY
|
322
|
+
positional_or_keyword = inspect.Parameter.POSITIONAL_OR_KEYWORD
|
323
|
+
|
324
|
+
# scipy.optimize.curve_fit assumes the first argument of the model function
|
325
|
+
# is the independent variable...
|
326
|
+
x, *parameters = inspect.signature(model_function).parameters.values()
|
327
|
+
# ... while all the others, internally, are passed positionally only
|
328
|
+
# (i.e., never as keywords), so here we cache all the names of the
|
329
|
+
# positional parameters.
|
330
|
+
parameter_names = [parameter.name for parameter in parameters if
|
331
|
+
parameter.kind in (positional_only, positional_or_keyword)]
|
332
|
+
|
333
|
+
# Make sure the constraints are valid, and we are not trying to freeze one
|
334
|
+
# or more non-existing parameter(s). This is actually clever, as it uses the fact
|
335
|
+
# that set(dict) returns the set of the keys, and after subtracting the two sets
|
336
|
+
# you end up with all the names of the unknown parameters, which is handy to
|
337
|
+
# print out an error message.
|
338
|
+
unknown_parameter_names = set(constraints) - set(parameter_names)
|
339
|
+
if unknown_parameter_names:
|
340
|
+
raise ValueError(f'Cannot freeze unknown parameters {unknown_parameter_names}')
|
341
|
+
|
342
|
+
# Now we need to build the signature for the new function, starting from a
|
343
|
+
# clean copy of the parameter for the independent variable...
|
344
|
+
parameters = [x.replace(default=inspect.Parameter.empty, kind=positional_or_keyword)]
|
345
|
+
# ... and following up with all the free parameters.
|
346
|
+
free_parameter_names = [name for name in parameter_names if name not in constraints]
|
347
|
+
num_free_parameters = len(free_parameter_names)
|
348
|
+
for name in free_parameter_names:
|
349
|
+
parameters.append(inspect.Parameter(name, kind=positional_or_keyword))
|
350
|
+
signature = inspect.Signature(parameters)
|
351
|
+
|
352
|
+
# And we have everything to prepare the glorious wrapper!
|
353
|
+
@functools.wraps(model_function)
|
354
|
+
def wrapper(x, *args):
|
355
|
+
if len(args) != num_free_parameters:
|
356
|
+
raise TypeError(f'Frozen wrapper got {len(args)} parameters instead of ' \
|
357
|
+
f'{num_free_parameters} ({free_parameter_names})')
|
358
|
+
parameter_dict = {**dict(zip(free_parameter_names, args)), **constraints}
|
359
|
+
return model_function(x, *[parameter_dict[name] for name in parameter_names])
|
360
|
+
|
361
|
+
wrapper.__signature__ = signature
|
362
|
+
return wrapper
|
363
|
+
|
364
|
+
def fit(self, xdata: ArrayLike, ydata: ArrayLike, p0: ArrayLike = None,
|
365
|
+
sigma: ArrayLike = 1., absolute_sigma: bool = False, xmin: float = -np.inf,
|
366
|
+
xmax: float = np.inf, **kwargs) -> None:
|
367
|
+
"""Fit a series of points.
|
368
|
+
"""
|
369
|
+
# Reset the fit status.
|
370
|
+
self.status.reset()
|
371
|
+
|
372
|
+
# Prepare the data. We want to make sure all the relevant things are numpy
|
373
|
+
# arrays so that we can vectorize operations downstream, taking advantage of
|
374
|
+
# the broadcast facilities.
|
375
|
+
xdata = np.asarray(xdata)
|
376
|
+
ydata = np.asarray(ydata)
|
377
|
+
# If we are fitting over a subrange, filter the input data.
|
378
|
+
mask = np.logical_and(xdata >= xmin, xdata <= xmax)
|
379
|
+
# (And, since we are at it, make sure we have enough degrees of freedom.)
|
380
|
+
self.status.dof = int(mask.sum() - len(self))
|
381
|
+
if self.status.dof < 0:
|
382
|
+
raise RuntimeError(f'{self.name()} has no degrees of freedom')
|
383
|
+
xdata = xdata[mask]
|
384
|
+
ydata = ydata[mask]
|
385
|
+
if not isinstance(sigma, Number):
|
386
|
+
sigma = np.asarray(sigma)[mask]
|
387
|
+
# Cache the fit range for later use.
|
388
|
+
self.status.fit_range = (xdata.min(), xdata.max())
|
389
|
+
|
390
|
+
# If we are not passing default starting points for the model parameters,
|
391
|
+
# try and do something sensible.
|
392
|
+
if p0 is None:
|
393
|
+
self.init_parameters(xdata, ydata, sigma)
|
394
|
+
p0 = self.free_parameter_values()
|
395
|
+
|
396
|
+
# Do the actual fit.
|
397
|
+
constraints = {parameter.name: parameter.value for parameter in self \
|
398
|
+
if parameter.frozen}
|
399
|
+
model = self.freeze(self.evaluate, **constraints)
|
400
|
+
args = model, xdata, ydata, p0, sigma, absolute_sigma, True, self.bounds()
|
401
|
+
popt, pcov = curve_fit(*args, **kwargs)
|
402
|
+
self._update_parameters(popt, pcov)
|
403
|
+
self.status.chisquare = self.calculate_chisqure(xdata, ydata, sigma)
|
404
|
+
return self.status
|
405
|
+
|
406
|
+
def default_plotting_range(self) -> Tuple[float, float]:
|
407
|
+
"""Return the default plotting range for the model.
|
408
|
+
|
409
|
+
This can be reimplemnted in concrete models, and can be parameter-dependent
|
410
|
+
(e.g., for a gaussian we might want to plot within 5 sigma from the mean by
|
411
|
+
dafeault).
|
412
|
+
"""
|
413
|
+
return (0., 1.)
|
414
|
+
|
415
|
+
def _plotting_range(self, xmin: float = None, xmax: float = None,
|
416
|
+
fit_padding: float = 0.) -> Tuple[float, float]:
|
417
|
+
"""Convenience function trying to come up with the most sensible plot range
|
418
|
+
for the model.
|
419
|
+
"""
|
420
|
+
# If we have fitted the model to some data, we take the fit range and pad it
|
421
|
+
# a little bit.
|
422
|
+
if self.status.fit_range is not None:
|
423
|
+
_xmin, _xmax = self.status.fit_range
|
424
|
+
fit_padding *= (_xmax - _xmin)
|
425
|
+
_xmin -= fit_padding
|
426
|
+
_xmax += fit_padding
|
427
|
+
# Otherwise we fall back to the default plotting range for the model.
|
428
|
+
else:
|
429
|
+
_xmin, _xmax = self.default_plotting_range()
|
430
|
+
# And are free to override either end!
|
431
|
+
if xmin is not None:
|
432
|
+
_xmin = xmin
|
433
|
+
if xmax is not None:
|
434
|
+
_xmax = xmax
|
435
|
+
return (_xmin, _xmax)
|
436
|
+
|
437
|
+
def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) -> None:
|
438
|
+
"""Plot the model.
|
439
|
+
"""
|
440
|
+
x = np.linspace(*self._plotting_range(xmin, xmax), num_points)
|
441
|
+
y = self(x)
|
442
|
+
plt.plot(x, y, label=format(self, Format.LATEX))
|
443
|
+
|
444
|
+
def __format__(self, spec: str) -> str:
|
445
|
+
"""String formatting.
|
446
|
+
"""
|
447
|
+
text = f'{self.__class__.__name__} ({format(self.status, spec)})\n'
|
448
|
+
for parameter in self._parameters:
|
449
|
+
text = f'{text}{format(parameter, spec)}\n'
|
450
|
+
return text.strip('\n')
|
451
|
+
|
452
|
+
def __str__(self):
|
453
|
+
"""String formatting.
|
454
|
+
"""
|
455
|
+
return format(self, Format.PRETTY)
|
456
|
+
|
457
|
+
|
458
|
+
class Constant(AbstractFitModel):
|
459
|
+
|
460
|
+
"""Constant model.
|
461
|
+
"""
|
462
|
+
|
463
|
+
value = FitParameter(1.)
|
464
|
+
|
465
|
+
@staticmethod
|
466
|
+
def evaluate(x: ArrayLike, value: float) -> ArrayLike:
|
467
|
+
# pylint: disable=arguments-differ
|
468
|
+
return np.full(value, x.shape)
|
469
|
+
|
470
|
+
|
471
|
+
class Line(AbstractFitModel):
|
472
|
+
|
473
|
+
"""Linear model.
|
474
|
+
"""
|
475
|
+
|
476
|
+
slope = FitParameter(1.)
|
477
|
+
intercept = FitParameter(0.)
|
478
|
+
|
479
|
+
@staticmethod
|
480
|
+
def evaluate(x: ArrayLike, slope: float, intercept: float) -> ArrayLike:
|
481
|
+
# pylint: disable=arguments-differ
|
482
|
+
return slope * x + intercept
|
483
|
+
|
484
|
+
|
485
|
+
class PowerLaw(AbstractFitModel):
|
486
|
+
|
487
|
+
"""Power-law model.
|
488
|
+
"""
|
489
|
+
|
490
|
+
prefactor = FitParameter(1.)
|
491
|
+
index = FitParameter(-1.)
|
492
|
+
|
493
|
+
@staticmethod
|
494
|
+
def evaluate(x: ArrayLike, prefactor: float, index: float) -> ArrayLike:
|
495
|
+
# pylint: disable=arguments-differ
|
496
|
+
return prefactor * x**index
|
497
|
+
|
498
|
+
|
499
|
+
class Gaussian(AbstractFitModel):
|
500
|
+
|
501
|
+
"""Gaussian model.
|
502
|
+
"""
|
503
|
+
|
504
|
+
prefactor = FitParameter(1.)
|
505
|
+
mean = FitParameter(0.)
|
506
|
+
sigma = FitParameter(1.)
|
507
|
+
|
508
|
+
@staticmethod
|
509
|
+
def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
|
510
|
+
# pylint: disable=arguments-differ
|
511
|
+
return prefactor * np.exp(-0.5 * ((x - mean) / sigma) ** 2.)
|
512
|
+
|
513
|
+
def default_plotting_range(self, num_sigma: int = 5) -> Tuple[float, float]:
|
514
|
+
mean, half_width = self.mean.value, num_sigma * self.sigma.value
|
515
|
+
return (mean - half_width, mean + half_width)
|