aptapy 0.2.0__py3-none-any.whl → 0.3.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/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.2.0"
1
+ __version__ = "0.3.1"
aptapy/hist.py CHANGED
@@ -34,13 +34,13 @@ class AbstractHistogram(ABC):
34
34
  edges : n-dimensional sequence of arrays
35
35
  the bin edges on the different axes.
36
36
 
37
- labels : sequence of strings
38
- the text labels for the different axes.
37
+ axis_labels : sequence of strings
38
+ the text labels for the different histogram axes.
39
39
  """
40
40
 
41
41
  DEFAULT_PLOT_OPTIONS = {}
42
42
 
43
- def __init__(self, edges: Sequence[np.ndarray], labels: List[str]) -> None:
43
+ def __init__(self, edges: Sequence[np.ndarray], label: str, axis_labels: List[str]) -> None:
44
44
  """Constructor.
45
45
  """
46
46
  # Edges are fixed once and forever, so we create a copy. Also, no matter
@@ -56,14 +56,15 @@ class AbstractHistogram(ABC):
56
56
  raise ValueError(f"Bin edges {item} have less than 2 entries.")
57
57
  if np.any(np.diff(item) <= 0):
58
58
  raise ValueError(f"Bin edges {item} not strictly increasing.")
59
- if labels is not None and len(labels) > self._num_axes + 1:
60
- raise ValueError(f"Too many labels {labels} for {self._num_axes} axes.")
59
+ if axis_labels is not None and len(axis_labels) > self._num_axes + 1:
60
+ raise ValueError(f"Too many axis labels {axis_labels} for {self._num_axes} axes.")
61
61
 
62
62
  # Go ahead and create all the necessary data structures.
63
63
  self._shape = tuple(item.size - 1 for item in self._edges)
64
64
  self._sumw = np.zeros(self._shape, dtype=float)
65
65
  self._sumw2 = np.zeros(self._shape, dtype=float)
66
- self._labels = labels
66
+ self.label = label
67
+ self.axis_labels = axis_labels
67
68
 
68
69
  @property
69
70
  def content(self) -> np.ndarray:
@@ -115,7 +116,7 @@ class AbstractHistogram(ABC):
115
116
  # Note we really need the * in the constructor, here, as the abstract
116
117
  # base class is never instantiated, and the arguments are unpacked in the
117
118
  # constructors of all the derived classes.
118
- histogram = self.__class__(*self._edges, *self._labels)
119
+ histogram = self.__class__(*self._edges, self.label, *self.axis_labels)
119
120
  histogram._sumw = self._sumw.copy()
120
121
  histogram._sumw2 = self._sumw2.copy()
121
122
  return histogram
@@ -185,33 +186,86 @@ class AbstractHistogram(ABC):
185
186
  class Histogram1d(AbstractHistogram):
186
187
 
187
188
  """One-dimensional histogram.
189
+
190
+ Arguments
191
+ ---------
192
+ edges : 1-dimensional array
193
+ the bin edges.
194
+
195
+ label : str
196
+ overall label for the histogram (if defined, this will be used in the
197
+ legend by default).
198
+
199
+ xlabel : str
200
+ the text label for the x axis.
201
+
202
+ ylabel : str
203
+ the text label for the y axis (default: "Entries/bin").
188
204
  """
189
205
 
190
206
  DEFAULT_PLOT_OPTIONS = dict(linewidth=1.25, alpha=0.4, histtype="stepfilled")
191
207
 
192
- def __init__(self, xedges: np.ndarray, xlabel: str = "", ylabel: str = "Entries/bin") -> None:
208
+ def __init__(self, xedges: np.ndarray, label: str = None, xlabel: str = None,
209
+ ylabel: str = "Entries/bin") -> None:
193
210
  """Constructor.
194
211
  """
195
- super().__init__((xedges, ), [xlabel, ylabel])
212
+ super().__init__((xedges, ), label, [xlabel, ylabel])
213
+
214
+ def area(self) -> float:
215
+ """Return the total area under the histogram.
216
+
217
+ This is potentially useful when fitting a model to the histogram, e.g.,
218
+ to freeze the prefactor of a gaussian to the histogram normalization.
219
+
220
+ Returns
221
+ -------
222
+ area : float
223
+ The total area under the histogram.
224
+ """
225
+ return (self.content * self.bin_widths()).sum()
196
226
 
197
227
  def _do_plot(self, axes: matplotlib.axes._axes.Axes, **kwargs) -> None:
198
228
  """Overloaded make_plot() method.
199
229
  """
230
+ # If we are not explicitly providing a label at plotting time, use
231
+ # the one attached to the histogram, if any.
232
+ kwargs.setdefault('label', self.label)
200
233
  axes.hist(self.bin_centers(0), self._edges[0], weights=self.content, **kwargs)
201
- setup_axes(axes, xlabel=self._labels[0], ylabel=self._labels[1])
234
+ setup_axes(axes, xlabel=self.axis_labels[0], ylabel=self.axis_labels[1])
202
235
 
203
236
 
204
237
  class Histogram2d(AbstractHistogram):
205
238
 
206
239
  """Two-dimensional histogram.
240
+
241
+ Arguments
242
+ ---------
243
+ xedges : 1-dimensional array
244
+ the bin edges on the x axis.
245
+
246
+ yedges : 1-dimensional array
247
+ the bin edges on the y axis.
248
+
249
+ label : str
250
+ overall label for the histogram
251
+
252
+ xlabel : str
253
+ the text label for the x axis.
254
+
255
+ ylabel : str
256
+ the text label for the y axis.
257
+
258
+ zlabel : str
259
+ the text label for the z axis (default: "Entries/bin").
207
260
  """
208
261
 
209
262
  DEFAULT_PLOT_OPTIONS = dict(cmap=plt.get_cmap('hot'))
210
263
 
211
- def __init__(self, xedges, yedges, xlabel='', ylabel='', zlabel='Entries/bin') -> None:
264
+ def __init__(self, xedges, yedges, label: str = None, xlabel: str = None,
265
+ ylabel: str = None, zlabel: str = 'Entries/bin') -> None:
212
266
  """Constructor.
213
267
  """
214
- super().__init__((xedges, yedges), [xlabel, ylabel, zlabel])
268
+ super().__init__((xedges, yedges), label, [xlabel, ylabel, zlabel])
215
269
 
216
270
  def _do_plot(self, axes: matplotlib.axes._axes.Axes, logz: bool = False, **kwargs) -> None:
217
271
  """Overloaded make_plot() method.
@@ -222,5 +276,5 @@ class Histogram2d(AbstractHistogram):
222
276
  vmax = kwargs.pop('vmax', None)
223
277
  kwargs.setdefault('norm', matplotlib.colors.LogNorm(vmin, vmax))
224
278
  mappable = axes.pcolormesh(*self._edges, self.content.T, **kwargs)
225
- plt.colorbar(mappable, ax=axes, label=self._labels[2])
226
- setup_axes(axes, xlabel=self._labels[0], ylabel=self._labels[1])
279
+ plt.colorbar(mappable, ax=axes, label=self.axis_labels[2])
280
+ setup_axes(axes, xlabel=self.axis_labels[0], ylabel=self.axis_labels[1])
aptapy/modeling.py CHANGED
@@ -21,15 +21,18 @@ import functools
21
21
  import inspect
22
22
  from abc import ABC, abstractmethod
23
23
  from dataclasses import dataclass
24
+ from itertools import chain
24
25
  from numbers import Number
25
- from typing import Iterator, Tuple
26
+ from typing import Callable, Iterator, Sequence, Tuple
26
27
 
27
28
  import matplotlib.pyplot as plt
28
29
  import numpy as np
29
30
  import uncertainties
30
31
  from scipy.optimize import curve_fit
32
+ from scipy.stats import chi2
31
33
 
32
- from aptapy.typing_ import ArrayLike
34
+ from .hist import Histogram1d
35
+ from .typing_ import ArrayLike
33
36
 
34
37
 
35
38
  class Format(str, enum.Enum):
@@ -63,6 +66,11 @@ class FitParameter:
63
66
 
64
67
  We are wrapping this into a property because, arguably, the parameter name is
65
68
  the only thing we never, ever want to change after the fact.
69
+
70
+ Returns
71
+ -------
72
+ name : str
73
+ The parameter name.
66
74
  """
67
75
  return self._name
68
76
 
@@ -72,11 +80,21 @@ class FitParameter:
72
80
 
73
81
  We are wrapping this into a property because we interact with this member
74
82
  via the freeze() and thaw() methods.
83
+
84
+ Returns
85
+ -------
86
+ frozen : bool
87
+ True if the parameter is frozen.
75
88
  """
76
89
  return self._frozen
77
90
 
78
91
  def is_bound(self) -> bool:
79
92
  """Return True if the parameter is bounded.
93
+
94
+ Returns
95
+ -------
96
+ bounded : bool
97
+ True if the parameter is bounded.
80
98
  """
81
99
  return not np.isinf(self.minimum) or not np.isinf(self.maximum)
82
100
 
@@ -94,7 +112,12 @@ class FitParameter:
94
112
  Arguments
95
113
  ---------
96
114
  name : str
97
- The name for the new FitParameter object.
115
+ The name for the new :class:`FitParameter` object.
116
+
117
+ Returns
118
+ -------
119
+ parameter : FitParameter
120
+ The new :class:`FitParameter` object.
98
121
  """
99
122
  return self.__class__(self.value, name, minimum=self.minimum, maximum=self.maximum)
100
123
 
@@ -132,11 +155,70 @@ class FitParameter:
132
155
 
133
156
  def ufloat(self) -> uncertainties.ufloat:
134
157
  """Return the parameter value and error as a ufloat object.
158
+
159
+ Returns
160
+ -------
161
+ ufloat : uncertainties.ufloat
162
+ The parameter value and error as a ufloat object.
135
163
  """
136
164
  return uncertainties.ufloat(self.value, self.error)
137
165
 
166
+ def pull(self, expected: float) -> float:
167
+ """Calculate the pull of the parameter with respect to a given expected value.
168
+
169
+ Arguments
170
+ ---------
171
+ expected : float
172
+ The expected value for the parameter.
173
+
174
+ Returns
175
+ -------
176
+ pull : float
177
+ The pull of the parameter with respect to the expected value, defined as
178
+ (value - expected) / error.
179
+
180
+ Raises
181
+ ------
182
+ RuntimeError
183
+ If the parameter has no error associated to it.
184
+ """
185
+ if self.error is None or self.error <= 0.:
186
+ raise RuntimeError(f"Cannot calculate pull for parameter {self.name} "
187
+ "with no error")
188
+ return (self.value - expected) / self.error
189
+
190
+ def compatible_with(self, expected: float, num_sigma: float = 3.) -> bool:
191
+ """Check if the parameter is compatible with an expected value within
192
+ n_sigma.
193
+
194
+ Arguments
195
+ ---------
196
+ expected : float
197
+ The expected value for the parameter.
198
+
199
+ num_sigma : float, optional
200
+ The number of sigmas to use for the compatibility check (default 3).
201
+
202
+ Returns
203
+ -------
204
+ compatible : bool
205
+ True if the parameter is compatible with the expected value within
206
+ num_sigma.
207
+ """
208
+ return abs(self.pull(expected)) <= num_sigma
209
+
138
210
  def __format__(self, spec: str) -> str:
139
211
  """String formatting.
212
+
213
+ Arguments
214
+ ---------
215
+ spec : str
216
+ The format specification.
217
+
218
+ Returns
219
+ -------
220
+ text : str
221
+ The formatted string.
140
222
  """
141
223
  # Keep in mind Python passes an empty string explicitly when you call
142
224
  # f"{parameter}", so we can't really assign a default value to spec.
@@ -145,8 +227,10 @@ class FitParameter:
145
227
  if spec.endswith(Format.LATEX):
146
228
  param = f"${param}$"
147
229
  else:
148
- spec = spec.rstrip(Format.PRETTY).rstrip(Format.LATEX)
149
- param = format(self.value, spec)
230
+ # Note in this case we are not passing the format spec to format(), as
231
+ # the only thing we can do in absence of an error is to use the
232
+ # Python default formatting.
233
+ param = format(self.value, "g")
150
234
  text = f"{self._name.title()}: {param}"
151
235
  info = []
152
236
  if self._frozen:
@@ -165,6 +249,11 @@ class FitParameter:
165
249
  This is meant to provide a more human-readable version of the parameter formatting
166
250
  than the default ``__repr__`` implementation from the dataclass decorator, and it
167
251
  is what is used in the actual printout of the fit parameters from a fit.
252
+
253
+ Returns
254
+ -------
255
+ text : str
256
+ The formatted string.
168
257
  """
169
258
  return format(self, Format.PRETTY)
170
259
 
@@ -177,7 +266,7 @@ class FitStatus:
177
266
 
178
267
  chisquare: float = None
179
268
  dof: int = None
180
- # pvalue: float = None
269
+ pvalue: float = None
181
270
  fit_range: Tuple[float, float] = None
182
271
 
183
272
  def reset(self) -> None:
@@ -185,10 +274,44 @@ class FitStatus:
185
274
  """
186
275
  self.chisquare = None
187
276
  self.dof = None
277
+ self.pvalue = None
188
278
  self.fit_range = None
189
279
 
280
+ def update(self, chisquare: float, dof: int = None) -> None:
281
+ """Update the fit status, i.e., set the chisquare and calculate the
282
+ corresponding p-value.
283
+
284
+ Arguments
285
+ ---------
286
+ chisquare : float
287
+ The chisquare of the fit.
288
+
289
+ dof : int, optional
290
+ The number of degrees of freedom of the fit.
291
+ """
292
+ self.chisquare = chisquare
293
+ if dof is not None:
294
+ self.dof = dof
295
+ self.pvalue = chi2.sf(self.chisquare, self.dof)
296
+ # chi2.sf() returns the survival function, i.e., 1 - cdf. If the survival
297
+ # function is > 0.5, we flip it around, so that we always report the smallest
298
+ # tail, and the pvalue is the probability of obtaining a chisquare value more
299
+ # `extreme` of the one we got.
300
+ if self.pvalue > 0.5:
301
+ self.pvalue = 1. - self.pvalue
302
+
190
303
  def __format__(self, spec: str) -> str:
191
304
  """String formatting.
305
+
306
+ Arguments
307
+ ---------
308
+ spec : str
309
+ The format specification.
310
+
311
+ Returns
312
+ -------
313
+ text : str
314
+ The formatted string.
192
315
  """
193
316
  if self.chisquare is None:
194
317
  return "N/A"
@@ -200,119 +323,227 @@ class FitStatus:
200
323
 
201
324
  def __str__(self) -> str:
202
325
  """String formatting.
326
+
327
+ Returns
328
+ -------
329
+ text : str
330
+ The formatted string.
203
331
  """
204
332
  return format(self, Format.PRETTY)
205
333
 
206
334
 
207
- class AbstractFitModel(ABC):
335
+ class AbstractFitModelBase(ABC):
208
336
 
209
- """Abstract base class for a fit model.
337
+ """Abstract base class for all the fit classes.
338
+
339
+ This is a acting a base class for both simple fit models and for composite models
340
+ (e.g., sums of simple ones).
210
341
  """
211
342
 
212
343
  def __init__(self) -> None:
213
344
  """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
345
  """
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
346
  self.status = FitStatus()
229
347
 
230
- def name(self) -> str:
231
- """Return the model name.
232
- """
233
- return self.__class__.__name__
234
-
348
+ @abstractmethod
235
349
  def __len__(self) -> int:
236
- """Overloaded method.
237
- """
238
- return len(self._parameters)
350
+ """Delegated to concrete classes: this should return the `total` number of
351
+ fit parameters (not only the free ones) in the model.
239
352
 
240
- def __iter__(self) -> Iterator[FitParameter]:
241
- """Iteration protocol.
242
- """
243
- return iter(self._parameters)
353
+ .. note::
244
354
 
245
- def parameter_values(self) -> Tuple[float]:
246
- """Return the current parameter values.
247
- """
248
- return tuple(parameter.value for parameter in self)
355
+ I still have mixed feelings about this method, as it is not clear whether
356
+ we are returning the number of parameters, or the number of free parameters,
357
+ but I think it is fine, as long as we document it. Also note that, while
358
+ the number of parameters is fixed once and for all for simple models,
359
+ it can change at runtime for composite models.
249
360
 
250
- def free_parameters(self) -> Tuple[FitParameter]:
251
- """Return the list of free parameters.
361
+ Returns
362
+ -------
363
+ n : int
364
+ The total number of fit parameters in the model.
252
365
  """
253
- return tuple(parameter for parameter in self if not parameter.frozen)
254
366
 
255
- def free_parameter_values(self) -> Tuple[float]:
256
- """Return the current parameter values.
367
+ @abstractmethod
368
+ def __iter__(self) -> Iterator[FitParameter]:
369
+ """Delegated to concrete classes: this should return an iterator over `all`
370
+ the fit parameters in the model.
371
+
372
+ Returns
373
+ -------
374
+ iterator : Iterator[FitParameter]
375
+ An iterator over all the fit parameters in the model.
257
376
  """
258
- return tuple(parameter.value for parameter in self.free_parameters())
259
377
 
260
378
  @staticmethod
261
379
  @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.
380
+ def evaluate(x: ArrayLike, *parameter_values: Sequence[float]) -> ArrayLike:
381
+ """Evaluate the model at a given set of parameter values.
265
382
 
266
383
  Arguments
267
384
  ---------
268
385
  x : array_like
269
386
  The value(s) of the independent variable.
270
387
 
271
- params : tuple of float
388
+ parameter_values : sequence of float
272
389
  The value of the model parameters.
390
+
391
+ Returns
392
+ -------
393
+ y : array_like
394
+ The value(s) of the model at the given value(s) of the independent variable
395
+ for a given set of parameter values.
273
396
  """
274
397
 
398
+ def name(self) -> str:
399
+ """Return the model name, e.g., for legends.
400
+
401
+ Note this can be reimplemented in concrete subclasses, but it should provide
402
+ a sensible default value in most circumstances.
403
+
404
+ Returns
405
+ -------
406
+ name : str
407
+ The model name.
408
+ """
409
+ return self.__class__.__name__
410
+
275
411
  def __call__(self, x: ArrayLike) -> ArrayLike:
276
412
  """Evaluate the model at the current value of the parameters.
413
+
414
+ Arguments
415
+ ---------
416
+ x : array_like
417
+ The value(s) of the independent variable.
418
+
419
+ Returns
420
+ -------
421
+ y : array_like
422
+ The value(s) of the model at the given value(s) of the independent variable
423
+ for the current set of parameter values.
277
424
  """
278
425
  return self.evaluate(x, *self.parameter_values())
279
426
 
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
427
+ def init_parameters(self, xdata: ArrayLike, ydata: ArrayLike, sigma: ArrayLike) -> None:
428
+ """Optional hook to change the current parameter values of the model, prior
429
+ to a fit, based on the input data.
430
+
431
+ Arguments
432
+ ---------
433
+ xdata : array_like
434
+ The input values of the independent variable.
435
+
436
+ ydata : array_like
437
+ The input values of the dependent variable.
285
438
 
286
- def init_parameters(self, x: ArrayLike, y: ArrayLike, sigma: ArrayLike) -> None:
287
- """Optional: override in subclasses if needed.
439
+ sigma : array_like
440
+ The input uncertainties on the dependent variable.
288
441
  """
289
442
  # pylint: disable=unused-argument
290
443
  return
291
444
 
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.
445
+ def parameter_values(self) -> Tuple[float]:
446
+ """Return the current parameter values.
447
+
448
+ Note this only relies on the __iter__() method, so it works both for simple
449
+ and composite models.
450
+
451
+ Returns
452
+ -------
453
+ values : tuple of float
454
+ The current parameter values.
294
455
  """
295
- for parameter, value, error in zip(self.free_parameters(), popt, np.sqrt(pcov.diagonal())):
296
- parameter.value = value
297
- parameter.error = error
456
+ return tuple(parameter.value for parameter in self)
457
+
458
+ def free_parameters(self) -> Tuple[FitParameter]:
459
+ """Return the list of free parameters.
460
+
461
+ Note this only relies on the __iter__() method, so it works both for simple
462
+ and composite models.
463
+
464
+ Returns
465
+ -------
466
+ parameters : tuple of FitParameter
467
+ The list of free parameters.
468
+ """
469
+ return tuple(parameter for parameter in self if not parameter.frozen)
470
+
471
+ def free_parameter_values(self) -> Tuple[float]:
472
+ """Return the current parameter values.
473
+
474
+ Returns
475
+ -------
476
+ values : tuple of float
477
+ The current parameter values.
478
+ """
479
+ return tuple(parameter.value for parameter in self.free_parameters())
298
480
 
299
481
  def bounds(self) -> Tuple[ArrayLike, ArrayLike]:
300
482
  """Return the bounds on the fit parameters in a form that can be use by the
301
483
  fitting method.
484
+
485
+ Returns
486
+ -------
487
+ bounds : 2-tuple of array_like
488
+ The lower and upper bounds on the (free) fit parameters.
302
489
  """
303
490
  free_parameters = self.free_parameters()
304
491
  return (tuple(parameter.minimum for parameter in free_parameters),
305
492
  tuple(parameter.maximum for parameter in free_parameters))
306
493
 
307
- def calculate_chisqure(self, xdata: np.ndarray, ydata: np.ndarray, sigma) -> float:
494
+ def update_parameters(self, popt: np.ndarray, pcov: np.ndarray) -> None:
495
+ """Update the model parameters based on the output of the ``curve_fit()`` call.
496
+
497
+ Arguments
498
+ ---------
499
+ popt : array_like
500
+ The optimal values for the fit parameters.
501
+
502
+ pcov : array_like
503
+ The covariance matrix for the fit parameters.
504
+ """
505
+ for parameter, value, error in zip(self.free_parameters(), popt, np.sqrt(pcov.diagonal())):
506
+ parameter.value = value
507
+ parameter.error = error
508
+
509
+ def calculate_chisquare(self, xdata: np.ndarray, ydata: np.ndarray, sigma) -> float:
308
510
  """Calculate the chisquare of the fit to some input data with the current
309
511
  model parameters.
512
+
513
+ Arguments
514
+ ---------
515
+ xdata : array_like
516
+ The input values of the independent variable.
517
+
518
+ ydata : array_like
519
+ The input values of the dependent variable.
520
+
521
+ sigma : array_like
522
+ The input uncertainties on the dependent variable.
523
+
524
+ Returns
525
+ -------
526
+ chisquare : float
527
+ The chisquare of the fit.
310
528
  """
311
529
  return float((((ydata - self(xdata)) / sigma)**2.).sum())
312
530
 
313
531
  @staticmethod
314
- def freeze(model_function, **constraints):
532
+ def freeze(model_function, **constraints) -> Callable:
315
533
  """Freeze a subset of the model parameters.
534
+
535
+ Arguments
536
+ ---------
537
+ model_function : callable
538
+ The model function to freeze parameters for.
539
+
540
+ constraints : dict
541
+ The parameters to freeze, as keyword arguments.
542
+
543
+ Returns
544
+ -------
545
+ wrapper : callable
546
+ A wrapper around the model function with the given parameters frozen.
316
547
  """
317
548
  if not constraints:
318
549
  return model_function
@@ -363,8 +594,36 @@ class AbstractFitModel(ABC):
363
594
 
364
595
  def fit(self, xdata: ArrayLike, ydata: ArrayLike, p0: ArrayLike = None,
365
596
  sigma: ArrayLike = 1., absolute_sigma: bool = False, xmin: float = -np.inf,
366
- xmax: float = np.inf, **kwargs) -> None:
597
+ xmax: float = np.inf, **kwargs) -> FitStatus:
367
598
  """Fit a series of points.
599
+
600
+ Arguments
601
+ ---------
602
+ xdata : array_like
603
+ The input values of the independent variable.
604
+
605
+ ydata : array_like
606
+ The input values of the dependent variable.
607
+
608
+ p0 : array_like, optional
609
+ The initial values for the fit parameters.
610
+
611
+ sigma : array_like
612
+ The input uncertainties on the dependent variable.
613
+
614
+ absolute_sigma : bool, optional (default False)
615
+ See the `curve_fit()` documentation for details.
616
+
617
+ xmin : float, optional (default -inf)
618
+ The minimum value of the independent variable to fit.
619
+
620
+ xmax : float, optional (default inf)
621
+ The maximum value of the independent variable to fit.
622
+
623
+ Returns
624
+ -------
625
+ status : FitStatus
626
+ The status of the fit.
368
627
  """
369
628
  # Reset the fit status.
370
629
  self.status.reset()
@@ -374,16 +633,21 @@ class AbstractFitModel(ABC):
374
633
  # the broadcast facilities.
375
634
  xdata = np.asarray(xdata)
376
635
  ydata = np.asarray(ydata)
636
+ if isinstance(sigma, Number):
637
+ sigma = np.full(ydata.shape, sigma)
638
+ sigma = np.asarray(sigma)
377
639
  # If we are fitting over a subrange, filter the input data.
378
640
  mask = np.logical_and(xdata >= xmin, xdata <= xmax)
641
+ # Also, filter out any points with non-positive uncertainties.
642
+ mask = np.logical_and(mask, sigma > 0.)
379
643
  # (And, since we are at it, make sure we have enough degrees of freedom.)
380
- self.status.dof = int(mask.sum() - len(self))
644
+ self.status.dof = int(mask.sum() - len(self.free_parameters()))
381
645
  if self.status.dof < 0:
382
646
  raise RuntimeError(f"{self.name()} has no degrees of freedom")
383
647
  xdata = xdata[mask]
384
648
  ydata = ydata[mask]
385
- if not isinstance(sigma, Number):
386
- sigma = np.asarray(sigma)[mask]
649
+ sigma = sigma[mask]
650
+
387
651
  # Cache the fit range for later use.
388
652
  self.status.fit_range = (xdata.min(), xdata.max())
389
653
 
@@ -399,16 +663,38 @@ class AbstractFitModel(ABC):
399
663
  model = self.freeze(self.evaluate, **constraints)
400
664
  args = model, xdata, ydata, p0, sigma, absolute_sigma, True, self.bounds()
401
665
  popt, pcov = curve_fit(*args, **kwargs)
402
- self._update_parameters(popt, pcov)
403
- self.status.chisquare = self.calculate_chisqure(xdata, ydata, sigma)
666
+ self.update_parameters(popt, pcov)
667
+ self.status.update(self.calculate_chisquare(xdata, ydata, sigma))
404
668
  return self.status
405
669
 
670
+ def fit_histogram(self, histogram: Histogram1d, p0: ArrayLike = None, **kwargs) -> None:
671
+ """Convenience function for fitting a 1-dimensional histogram.
672
+
673
+ Arguments
674
+ ---------
675
+ histogram : Histogram1d
676
+ The histogram to fit.
677
+
678
+ p0 : array_like, optional
679
+ The initial values for the fit parameters.
680
+
681
+ kwargs : dict, optional
682
+ Additional keyword arguments passed to `fit()`.
683
+ """
684
+ args = histogram.bin_centers(), histogram.content, p0, histogram.errors
685
+ return self.fit(*args, **kwargs)
686
+
406
687
  def default_plotting_range(self) -> Tuple[float, float]:
407
688
  """Return the default plotting range for the model.
408
689
 
409
- This can be reimplemnted in concrete models, and can be parameter-dependent
690
+ This can be reimplemented in concrete models, and can be parameter-dependent
410
691
  (e.g., for a gaussian we might want to plot within 5 sigma from the mean by
411
- dafeault).
692
+ default).
693
+
694
+ Returns
695
+ -------
696
+ Tuple[float, float]
697
+ The default plotting range for the model.
412
698
  """
413
699
  return (0., 1.)
414
700
 
@@ -416,6 +702,22 @@ class AbstractFitModel(ABC):
416
702
  fit_padding: float = 0.) -> Tuple[float, float]:
417
703
  """Convenience function trying to come up with the most sensible plot range
418
704
  for the model.
705
+
706
+ Arguments
707
+ ---------
708
+ xmin : float, optional
709
+ The minimum value of the independent variable to plot.
710
+
711
+ xmax : float, optional
712
+ The maximum value of the independent variable to plot.
713
+
714
+ fit_padding : float, optional
715
+ The amount of padding to add to the fit range.
716
+
717
+ Returns
718
+ -------
719
+ Tuple[float, float]
720
+ The plotting range for the model.
419
721
  """
420
722
  # If we have fitted the model to some data, we take the fit range and pad it
421
723
  # a little bit.
@@ -434,27 +736,191 @@ class AbstractFitModel(ABC):
434
736
  _xmax = xmax
435
737
  return (_xmin, _xmax)
436
738
 
437
- def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) -> None:
739
+ def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) -> np.ndarray:
438
740
  """Plot the model.
741
+
742
+ Arguments
743
+ ---------
744
+ xmin : float, optional
745
+ The minimum value of the independent variable to plot.
746
+
747
+ xmax : float, optional
748
+ The maximum value of the independent variable to plot.
749
+
750
+ num_points : int, optional
751
+ The number of points to use for the plot.
752
+
753
+ Returns
754
+ -------
755
+ x : np.ndarray
756
+ The x values used for the plot, that can be used downstream to add
757
+ artists on the plot itself (e.g., composite models can use the same
758
+ grid to draw the components).
439
759
  """
440
760
  x = np.linspace(*self._plotting_range(xmin, xmax), num_points)
441
761
  y = self(x)
442
762
  plt.plot(x, y, label=format(self, Format.LATEX))
763
+ return x
443
764
 
444
765
  def __format__(self, spec: str) -> str:
445
766
  """String formatting.
446
- """
447
- text = f"{self.__class__.__name__} ({format(self.status, spec)})\n"
448
- for parameter in self._parameters:
767
+
768
+ Arguments
769
+ ---------
770
+ spec : str
771
+ The format specification.
772
+
773
+ Returns
774
+ -------
775
+ text : str
776
+ The formatted string.
777
+ """
778
+ text = f"{self.name()}\n"
779
+ if self.status is not None:
780
+ text = f"{text}{format(self.status, spec)}\n"
781
+ for parameter in self:
449
782
  text = f"{text}{format(parameter, spec)}\n"
450
783
  return text.strip("\n")
451
784
 
452
785
  def __str__(self):
453
786
  """String formatting.
787
+
788
+ Returns
789
+ -------
790
+ text : str
791
+ The formatted string.
454
792
  """
455
793
  return format(self, Format.PRETTY)
456
794
 
457
795
 
796
+ class AbstractFitModel(AbstractFitModelBase):
797
+
798
+ """Abstract base class for a fit model.
799
+ """
800
+
801
+ def __init__(self) -> None:
802
+ """Constructor.
803
+
804
+ Here we loop over the FitParameter objects defined at the class level, and
805
+ create copies that are attached to the instance, so that the latter has its
806
+ own state.
807
+ """
808
+ super().__init__()
809
+ self._parameters = []
810
+ for name, value in self.__class__.__dict__.items():
811
+ if isinstance(value, FitParameter):
812
+ parameter = value.copy(name)
813
+ # Note we also set one instance attribute for each parameter so
814
+ # that we can use the notation model.parameter
815
+ setattr(self, name, parameter)
816
+ self._parameters.append(parameter)
817
+
818
+ def __len__(self) -> int:
819
+ """Return the `total` number of fit parameters in the model.
820
+ """
821
+ return len(self._parameters)
822
+
823
+ def __iter__(self) -> Iterator[FitParameter]:
824
+ """Iterate over `all` the model parameters.
825
+ """
826
+ return iter(self._parameters)
827
+
828
+ def __add__(self, other):
829
+ """Model sum.
830
+ """
831
+ if not isinstance(other, AbstractFitModel):
832
+ raise TypeError(f"{other} is not a fit model")
833
+ return FitModelSum(self, other)
834
+
835
+
836
+ class FitModelSum(AbstractFitModelBase):
837
+
838
+ """Composite model representing the sum of an arbitrary number of simple models.
839
+
840
+ Arguments
841
+ ---------
842
+ components : sequence of AbstractFitModel
843
+ The components of the composite model.
844
+ """
845
+
846
+ def __init__(self, *components: AbstractFitModel) -> None:
847
+ """Constructor.
848
+ """
849
+ super().__init__()
850
+ self._components = components
851
+
852
+ def name(self) -> str:
853
+ """Return the model name.
854
+ """
855
+ return " + ".join(component.name() for component in self._components)
856
+
857
+ def __len__(self) -> int:
858
+ """Return the sum of `all` the fit parameters in the underlying models.
859
+ """
860
+ return sum(len(component) for component in self._components)
861
+
862
+ def __iter__(self) -> Iterator[FitParameter]:
863
+ """Iterate over `all` the parameters of the underlying components.
864
+ """
865
+ return chain(*self._components)
866
+
867
+ def evaluate(self, x: ArrayLike, *parameter_values) -> ArrayLike:
868
+ """Overloaded method for evaluating the model.
869
+
870
+ Note this is not a static method, as we need to access the list of components
871
+ to sum over.
872
+ """
873
+ # pylint: disable=arguments-differ
874
+ cursor = 0
875
+ value = np.zeros(x.shape)
876
+ for component in self._components:
877
+ value += component.evaluate(x, *parameter_values[cursor:cursor + len(component)])
878
+ cursor += len(component)
879
+ return value
880
+
881
+ def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) -> None:
882
+ """Overloaded method for plotting the model.
883
+ """
884
+ x = super().plot(xmin, xmax, num_points)
885
+ color = plt.gca().lines[-1].get_color()
886
+ for component in self._components:
887
+ y = component(x)
888
+ plt.plot(x, y, label=None, ls="--", color=color)
889
+
890
+ def __format__(self, spec: str) -> str:
891
+ """String formatting.
892
+
893
+ Arguments
894
+ ---------
895
+ spec : str
896
+ The format specification.
897
+
898
+ Returns
899
+ -------
900
+ text : str
901
+ The formatted string.
902
+ """
903
+ text = f"{self.name()}\n"
904
+ if self.status is not None:
905
+ text = f"{text}{format(self.status, spec)}\n"
906
+ for component in self._components:
907
+ text = f"{text}[{component.name()}]\n"
908
+ for parameter in component:
909
+ text = f"{text}{format(parameter, spec)}\n"
910
+ return text.strip("\n")
911
+
912
+ def __add__(self, other: AbstractFitModel) -> "FitModelSum":
913
+ """Implementation of the model sum (i.e., using the `+` operator).
914
+
915
+ Note that, in the spirit of keeping the interfaces as simple as possible,
916
+ we are not implementing in-place addition (i.e., `+=`), and we only
917
+ allow ``AbstractFitModel`` objects (not ``FitModelSum``) on the right
918
+ hand side, which is all is needed to support the sum of an arbitrary
919
+ number of models.
920
+ """
921
+ return self.__class__(*self._components, other)
922
+
923
+
458
924
  class Constant(AbstractFitModel):
459
925
 
460
926
  """Constant model.
@@ -465,7 +931,7 @@ class Constant(AbstractFitModel):
465
931
  @staticmethod
466
932
  def evaluate(x: ArrayLike, value: float) -> ArrayLike:
467
933
  # pylint: disable=arguments-differ
468
- return np.full(value, x.shape)
934
+ return np.full(x.shape, value)
469
935
 
470
936
 
471
937
  class Line(AbstractFitModel):
@@ -503,13 +969,27 @@ class Gaussian(AbstractFitModel):
503
969
 
504
970
  prefactor = FitParameter(1.)
505
971
  mean = FitParameter(0.)
506
- sigma = FitParameter(1.)
972
+ sigma = FitParameter(1., minimum=0.)
973
+
974
+ _NORM_CONSTANT = 1. / np.sqrt(2. * np.pi)
975
+ _SIGMA_TO_FWHM = 2. * np.sqrt(2. * np.log(2.))
507
976
 
508
977
  @staticmethod
509
978
  def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
510
979
  # pylint: disable=arguments-differ
511
- return prefactor * np.exp(-0.5 * ((x - mean) / sigma) ** 2.)
980
+ z = (x - mean) / sigma
981
+ return prefactor * Gaussian._NORM_CONSTANT / sigma * np.exp(-0.5 * z**2.)
512
982
 
513
983
  def default_plotting_range(self, num_sigma: int = 5) -> Tuple[float, float]:
514
984
  mean, half_width = self.mean.value, num_sigma * self.sigma.value
515
985
  return (mean - half_width, mean + half_width)
986
+
987
+ def fwhm(self) -> uncertainties.ufloat:
988
+ """Return the full-width at half-maximum (FWHM) of the gaussian.
989
+
990
+ Returns
991
+ -------
992
+ fwhm : uncertainties.ufloat
993
+ The FWHM of the gaussian.
994
+ """
995
+ return self.sigma.ufloat() * self._SIGMA_TO_FWHM
aptapy/plotting.py CHANGED
@@ -84,11 +84,18 @@ def _set(key: str, value: Any):
84
84
  logger.warning(f"{exception}, skipping...")
85
85
 
86
86
 
87
- def configure() -> None:
87
+ def configure(*args) -> None:
88
88
  """See https://matplotlib.org/stable/users/explain/customizing.html for more
89
89
  information.
90
+
91
+ .. note::
92
+
93
+ Note that this function can be used as a hook by Sphinx Gallery to
94
+ configure the plotting environment for each example, so that the matplotlib
95
+ configuration is consistent across all examples and is not reset each time.
96
+ This is the reason why the function signature includes unused arguments.
90
97
  """
91
- # pylint:disable=too-many-statements
98
+ # pylint:disable=too-many-statements, unused-argument
92
99
 
93
100
  # Backends
94
101
  _set("interactive", False)
aptapy/strip.py ADDED
@@ -0,0 +1,92 @@
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
+ """Strip charts.
17
+ """
18
+
19
+ import collections
20
+ from typing import Sequence
21
+
22
+ import numpy as np
23
+
24
+ from .plotting import plt, setup_axes
25
+
26
+
27
+ class StripChart:
28
+
29
+ """Class describing a sliding strip chart, that is, a scatter plot where the
30
+ number of points is limited to a maximum, so that the thing acts essentially
31
+ as a sliding window, typically in time.
32
+
33
+ Arguments
34
+ ---------
35
+ max_length : int, optional
36
+ the maximum number of points to keep in the strip chart. If None (the default),
37
+ the number of points is unlimited.
38
+
39
+ label : str, optional
40
+ a text label for the data series (default is None).
41
+
42
+ xlabel : str, optional
43
+ the label for the x axis.
44
+
45
+ ylabel : str, optional
46
+ the label for the y axis.
47
+
48
+ datetime : bool, optional
49
+ if True, the x values are treated as POSIX timestamps and converted to
50
+ datetime objects for plotting purposes (default is False).
51
+ """
52
+
53
+ def __init__(self, max_length: int = None, label: str = '', xlabel: str = None,
54
+ ylabel: str = None, datetime: bool = False) -> None:
55
+ """Constructor.
56
+ """
57
+ self.label = label
58
+ self.xlabel = xlabel
59
+ self.ylabel = ylabel
60
+ self._datetime = datetime
61
+ self.x = collections.deque(maxlen=max_length)
62
+ self.y = collections.deque(maxlen=max_length)
63
+
64
+ def clear(self) -> None:
65
+ """Reset the strip chart.
66
+ """
67
+ self.x.clear()
68
+ self.y.clear()
69
+
70
+ def append(self, x: float, y: float) -> None:
71
+ """Append a data point to the strip chart.
72
+ """
73
+ self.x.append(x)
74
+ self.y.append(y)
75
+
76
+ def extend(self, x: Sequence[float], y: Sequence[float]) -> None:
77
+ """Append multiple data points to the strip chart.
78
+ """
79
+ if len(x) != len(y):
80
+ raise ValueError("x and y must have the same length")
81
+ self.x.extend(x)
82
+ self.y.extend(y)
83
+
84
+ def plot(self, axes=None, **kwargs) -> None:
85
+ """Plot the strip chart.
86
+ """
87
+ kwargs.setdefault("label", self.label)
88
+ if axes is None:
89
+ axes = plt.gca()
90
+ x = np.array(self.x).astype('datetime64[s]') if self._datetime else self.x
91
+ axes.plot(x, self.y, **kwargs)
92
+ setup_axes(axes, xlabel=self.xlabel, ylabel=self.ylabel, grids=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aptapy
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Statistical tools for online monitoring and analysis
5
5
  Project-URL: Homepage, https://github.com/lucabaldini/aptapy
6
6
  Project-URL: Issues, https://github.com/lucabaldini/aptapy/issues
@@ -680,6 +680,14 @@ License: GNU GENERAL PUBLIC LICENSE
680
680
  Public License instead of this License. But first, please read
681
681
  <https://www.gnu.org/licenses/why-not-lgpl.html>.
682
682
  License-File: LICENSE
683
+ Classifier: Development Status :: 4 - Beta
684
+ Classifier: Intended Audience :: Science/Research
685
+ Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
686
+ Classifier: Operating System :: OS Independent
687
+ Classifier: Programming Language :: Python
688
+ Classifier: Programming Language :: Python :: 3.7
689
+ Classifier: Programming Language :: Python :: 3.13
690
+ Classifier: Topic :: Scientific/Engineering
683
691
  Requires-Python: >=3.7
684
692
  Requires-Dist: cycler
685
693
  Requires-Dist: loguru
@@ -694,9 +702,21 @@ Requires-Dist: pytest; extra == 'dev'
694
702
  Requires-Dist: ruff; extra == 'dev'
695
703
  Provides-Extra: docs
696
704
  Requires-Dist: sphinx; extra == 'docs'
705
+ Requires-Dist: sphinx-gallery; extra == 'docs'
697
706
  Requires-Dist: sphinxawesome-theme; extra == 'docs'
698
707
  Description-Content-Type: text/markdown
699
708
 
700
709
  <img src="docs/_static/logo.png" alt="logo" width="175"/>
701
710
 
702
- Statistical tools for online monitoring and analysis
711
+ [![PyPI](https://img.shields.io/pypi/v/aptapy.svg)](https://pypi.org/project/aptapy/)
712
+ ![Python versions](https://img.shields.io/badge/python-3.7--3.13-blue)
713
+ ![License](https://img.shields.io/github/license/lucabaldini/aptapy.svg)
714
+ [![CI](https://github.com/lucabaldini/aptapy/actions/workflows/ci.yml/badge.svg)](https://github.com/lucabaldini/aptapy/actions/workflows/ci.yml)
715
+ [![Docs](https://github.com/lucabaldini/aptapy/actions/workflows/docs.yml/badge.svg)](https://github.com/lucabaldini/aptapy/actions/workflows/docs.yml)
716
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://lucabaldini.github.io/aptapy/)
717
+ ![GitHub last commit](https://img.shields.io/github/last-commit/lucabaldini/aptapy)
718
+
719
+ [![Ceasefire Now](https://badge.techforpalestine.org/default)](https://techforpalestine.org/learn-more)
720
+
721
+ Statistical tools for online monitoring and analysis.
722
+ [Read more](https://lucabaldini.github.io/aptapy/).
@@ -0,0 +1,12 @@
1
+ aptapy/__init__.py,sha256=a7Au1ukdeJbjiIZ-UL-qZE1xk-d2WnKKkoqjg_0SzqA,1707
2
+ aptapy/_version.py,sha256=r4xAFihOf72W9TD-lpMi6ntWSTKTP2SlzKP1ytkjRbI,22
3
+ aptapy/hist.py,sha256=jvHULR2lW_gyoemNaRI-vpqhFqmLLCB319CaKjwU69E,9752
4
+ aptapy/modeling.py,sha256=mHHHFMYmMuXTRQD2yR1XLM6E4KaBk8md7Z7dDp5ReD8,32566
5
+ aptapy/plotting.py,sha256=p9YNdrcFcTimRCtoXcV3zORaEd4EfMtsDd4ETxGKKHM,27483
6
+ aptapy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ aptapy/strip.py,sha256=qGsVXWp-Dz-lz7KQxktMifUTNkSxh8ZQwYme8_bealQ,3026
8
+ aptapy/typing_.py,sha256=JIbEqKI8kn_fd90yDt0JmI1AojjmLhAEB_1RfMFxLx4,807
9
+ aptapy-0.3.1.dist-info/METADATA,sha256=KZP-aljwTsIKUi-1nE-X9ZfIxc44ijFhKCkoyK0EXjY,42796
10
+ aptapy-0.3.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ aptapy-0.3.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
12
+ aptapy-0.3.1.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- aptapy/__init__.py,sha256=a7Au1ukdeJbjiIZ-UL-qZE1xk-d2WnKKkoqjg_0SzqA,1707
2
- aptapy/_version.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
3
- aptapy/hist.py,sha256=5fiaYEnSQ7b_jSsZdRvXrIGr2vTMlW_55jkFpE8L3Sw,8158
4
- aptapy/modeling.py,sha256=V8DVfmwFyUfy9-YZXd9Hz5rlyGEy12xoyn-oJ4ss3W0,18236
5
- aptapy/plotting.py,sha256=ZixAVF83qIuITjzQJBUvMNCK-REilzyAt0vxgcMbCOk,27125
6
- aptapy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- aptapy/typing_.py,sha256=JIbEqKI8kn_fd90yDt0JmI1AojjmLhAEB_1RfMFxLx4,807
8
- aptapy-0.2.0.dist-info/METADATA,sha256=yvpj0nK8DKrbXcccDPqKeiruxJYAGEK0FYaIKa4UpS0,41456
9
- aptapy-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
- aptapy-0.2.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
11
- aptapy-0.2.0.dist-info/RECORD,,
File without changes