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 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)