aptapy 0.3.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (42) hide show
  1. {aptapy-0.3.1 → aptapy-0.4.0}/PKG-INFO +1 -2
  2. {aptapy-0.3.1 → aptapy-0.4.0}/README.md +0 -1
  3. {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/constrained_fit.py +3 -3
  4. aptapy-0.4.0/docs/examples/simple_hist2d.py +22 -0
  5. {aptapy-0.3.1 → aptapy-0.4.0}/docs/release_notes.rst +30 -0
  6. aptapy-0.4.0/src/aptapy/_version.py +1 -0
  7. {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/hist.py +56 -7
  8. {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/modeling.py +265 -21
  9. {aptapy-0.3.1 → aptapy-0.4.0}/tests/test_hist.py +32 -5
  10. {aptapy-0.3.1 → aptapy-0.4.0}/tests/test_modeling.py +97 -2
  11. aptapy-0.3.1/src/aptapy/_version.py +0 -1
  12. {aptapy-0.3.1 → aptapy-0.4.0}/.github/workflows/ci.yml +0 -0
  13. {aptapy-0.3.1 → aptapy-0.4.0}/.github/workflows/docs.yml +0 -0
  14. {aptapy-0.3.1 → aptapy-0.4.0}/.github/workflows/pypi.yml +0 -0
  15. {aptapy-0.3.1 → aptapy-0.4.0}/.gitignore +0 -0
  16. {aptapy-0.3.1 → aptapy-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  17. {aptapy-0.3.1 → aptapy-0.4.0}/CONTRIBUTING.md +0 -0
  18. {aptapy-0.3.1 → aptapy-0.4.0}/LICENSE +0 -0
  19. {aptapy-0.3.1 → aptapy-0.4.0}/docs/Makefile +0 -0
  20. {aptapy-0.3.1 → aptapy-0.4.0}/docs/_static/favicon.ico +0 -0
  21. {aptapy-0.3.1 → aptapy-0.4.0}/docs/_static/logo.png +0 -0
  22. {aptapy-0.3.1 → aptapy-0.4.0}/docs/_static/logo_small.png +0 -0
  23. {aptapy-0.3.1 → aptapy-0.4.0}/docs/conf.py +0 -0
  24. {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/GALLERY_HEADER.rst +0 -0
  25. {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/composite_fit.py +0 -0
  26. {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/simple_fit.py +0 -0
  27. {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/simple_hist1d.py +0 -0
  28. {aptapy-0.3.1 → aptapy-0.4.0}/docs/examples/weighted_hist1d.py +0 -0
  29. {aptapy-0.3.1 → aptapy-0.4.0}/docs/hist.rst +0 -0
  30. {aptapy-0.3.1 → aptapy-0.4.0}/docs/index.rst +0 -0
  31. {aptapy-0.3.1 → aptapy-0.4.0}/docs/make.bat +0 -0
  32. {aptapy-0.3.1 → aptapy-0.4.0}/docs/modeling.rst +0 -0
  33. {aptapy-0.3.1 → aptapy-0.4.0}/docs/strip.rst +0 -0
  34. {aptapy-0.3.1 → aptapy-0.4.0}/noxfile.py +0 -0
  35. {aptapy-0.3.1 → aptapy-0.4.0}/pyproject.toml +0 -0
  36. {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/__init__.py +0 -0
  37. {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/plotting.py +0 -0
  38. {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/py.typed +0 -0
  39. {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/strip.py +0 -0
  40. {aptapy-0.3.1 → aptapy-0.4.0}/src/aptapy/typing_.py +0 -0
  41. {aptapy-0.3.1 → aptapy-0.4.0}/tests/test_strip.py +0 -0
  42. {aptapy-0.3.1 → aptapy-0.4.0}/tools/release.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aptapy
3
- Version: 0.3.1
3
+ Version: 0.4.0
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
@@ -713,7 +713,6 @@ Description-Content-Type: text/markdown
713
713
  ![License](https://img.shields.io/github/license/lucabaldini/aptapy.svg)
714
714
  [![CI](https://github.com/lucabaldini/aptapy/actions/workflows/ci.yml/badge.svg)](https://github.com/lucabaldini/aptapy/actions/workflows/ci.yml)
715
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
716
  ![GitHub last commit](https://img.shields.io/github/last-commit/lucabaldini/aptapy)
718
717
 
719
718
  [![Ceasefire Now](https://badge.techforpalestine.org/default)](https://techforpalestine.org/learn-more)
@@ -5,7 +5,6 @@
5
5
  ![License](https://img.shields.io/github/license/lucabaldini/aptapy.svg)
6
6
  [![CI](https://github.com/lucabaldini/aptapy/actions/workflows/ci.yml/badge.svg)](https://github.com/lucabaldini/aptapy/actions/workflows/ci.yml)
7
7
  [![Docs](https://github.com/lucabaldini/aptapy/actions/workflows/docs.yml/badge.svg)](https://github.com/lucabaldini/aptapy/actions/workflows/docs.yml)
8
- [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://lucabaldini.github.io/aptapy/)
9
8
  ![GitHub last commit](https://img.shields.io/github/last-commit/lucabaldini/aptapy)
10
9
 
11
10
  [![Ceasefire Now](https://badge.techforpalestine.org/default)](https://techforpalestine.org/learn-more)
@@ -19,9 +19,9 @@ hist.fill(np.random.default_rng().normal(size=100000))
19
19
  hist.plot()
20
20
 
21
21
  model = Gaussian()
22
- # Fix the prefactor to the histogram area---note this only works because the
23
- # Gaussian model is normalized to 1 over the full range when the prefactor is 1.
24
- model.prefactor.freeze(hist.area())
22
+ # Fix the prefactor. This is a generally useful technique, as you should
23
+ # never fit the normalization of a histogram.
24
+ model.prefactor.freeze(hist.area() / model.integral(-5., 5.))
25
25
  model.fit_histogram(hist)
26
26
  print(model)
27
27
  model.plot()
@@ -0,0 +1,22 @@
1
+ """
2
+ Simple 2-D histogram
3
+ ====================
4
+
5
+ Simple two-dimensional histogram filled with bivariate gaussians random numbers.
6
+ """
7
+
8
+ # %%
9
+
10
+ import numpy as np
11
+
12
+ from aptapy.hist import Histogram2d
13
+ from aptapy.plotting import plt
14
+
15
+ edges = np.linspace(-5., 5., 100)
16
+ hist = Histogram2d(edges, edges, label="Random data", xlabel="x", ylabel="y")
17
+ x= np.random.default_rng().normal(loc=1., size=100000)
18
+ y= np.random.default_rng().normal(loc=-1., size=100000)
19
+ hist.fill(x, y)
20
+ hist.plot()
21
+
22
+ plt.gca().set_aspect("equal")
@@ -4,9 +4,39 @@ Release notes
4
4
  =============
5
5
 
6
6
 
7
+ Version 0.4.0 (2025-10-11)
8
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
9
+
10
+
11
+ * Added 2-dimensional histogram example.
12
+ * Adds several new model classes (Quadratic, PowerLaw, Exponential, Erf, ErfInverse).
13
+ * Implements analytical integration methods for models where possible, with a fallback
14
+ to numerical integration in the base class.
15
+ * Updates the FitStatus class with a completion check method.
16
+
17
+ * Pull requests merged:
18
+
19
+ - https://github.com/lucabaldini/aptapy/pull/7
20
+
21
+
22
+ Version 0.3.2 (2025-10-09)
23
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
24
+
25
+ * Adding binned_statistics method in AbstractHistogram base class to calculate
26
+ statistics from histogram bins
27
+ * Adds extensive test coverage in both 1D and 2D histogram test functions with
28
+ statistical validation
29
+
30
+ * Pull requests merged:
31
+
32
+ - https://github.com/lucabaldini/aptapy/pull/6
33
+
34
+
7
35
  Version 0.3.1 (2025-10-09)
8
36
  ~~~~~~~~~~~~~~~~~~~~~~~~~~
9
37
 
38
+ * Minor changes.
39
+
10
40
 
11
41
  Version 0.3.0 (2025-10-08)
12
42
  ~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -17,13 +17,17 @@
17
17
  """
18
18
 
19
19
  from abc import ABC, abstractmethod
20
- from typing import List, Sequence
20
+ from typing import List, Sequence, Tuple
21
21
 
22
22
  import numpy as np
23
23
 
24
24
  from .plotting import matplotlib, plt, setup_axes
25
25
  from .typing_ import ArrayLike
26
26
 
27
+ __all__ = [
28
+ "Histogram1d",
29
+ "Histogram2d",
30
+ ]
27
31
 
28
32
  class AbstractHistogram(ABC):
29
33
 
@@ -93,6 +97,43 @@ class AbstractHistogram(ABC):
93
97
  """
94
98
  return np.diff(self._edges[axis])
95
99
 
100
+ def binned_statistics(self, axis: int = 0) -> Tuple[float, float]:
101
+ """Return the mean and standard deviation along a specific axis, based
102
+ on the binned data.
103
+
104
+ .. note::
105
+
106
+ This is a crude estimate of the underlying statistics that might be
107
+ useful for monitoring purposes, but should not be relied upon for
108
+ quantitative analysis.
109
+
110
+ This is not the same as computing the mean and standard deviation of
111
+ the unbinned data that filled the histogram, as some information is
112
+ lost in the binning process.
113
+
114
+ In addition, note that we are not applying any bias correction to
115
+ the standard deviation, as we are assuming that the histogram is
116
+ filled with a sufficiently large number of entries. (In most circumstances
117
+ the effect should be smaller than that of the binning itself.)
118
+
119
+ Arguments
120
+ ---------
121
+ axis : int
122
+ the axis along which to compute the statistics.
123
+
124
+ Returns
125
+ -------
126
+ mean : float
127
+ the mean value along the specified axis.
128
+ stddev : float
129
+ the standard deviation along the specified axis.
130
+ """
131
+ values = self.bin_centers(axis)
132
+ weights = self.content.sum(axis=tuple(i for i in range(self.content.ndim) if i != axis))
133
+ mean = np.average(values, weights=weights)
134
+ variance = np.average((values - mean)**2, weights=weights)
135
+ return float(mean), float(np.sqrt(variance))
136
+
96
137
  def fill(self, *values: ArrayLike, weights: ArrayLike = None) -> "AbstractHistogram":
97
138
  """Fill the histogram from unbinned data.
98
139
 
@@ -229,10 +270,18 @@ class Histogram1d(AbstractHistogram):
229
270
  """
230
271
  # If we are not explicitly providing a label at plotting time, use
231
272
  # the one attached to the histogram, if any.
232
- kwargs.setdefault('label', self.label)
273
+ kwargs.setdefault("label", f"{self}")
233
274
  axes.hist(self.bin_centers(0), self._edges[0], weights=self.content, **kwargs)
234
275
  setup_axes(axes, xlabel=self.axis_labels[0], ylabel=self.axis_labels[1])
235
276
 
277
+ def __str__(self) -> str:
278
+ """String formatting.
279
+ """
280
+ mean, rms = self.binned_statistics()
281
+ text = self.label or self.__class__.__name__
282
+ text = f"{text}\nMean: {mean:g}\nRMS: {rms:g}"
283
+ return text
284
+
236
285
 
237
286
  class Histogram2d(AbstractHistogram):
238
287
 
@@ -259,10 +308,10 @@ class Histogram2d(AbstractHistogram):
259
308
  the text label for the z axis (default: "Entries/bin").
260
309
  """
261
310
 
262
- DEFAULT_PLOT_OPTIONS = dict(cmap=plt.get_cmap('hot'))
311
+ DEFAULT_PLOT_OPTIONS = dict(cmap=plt.get_cmap("hot"))
263
312
 
264
313
  def __init__(self, xedges, yedges, label: str = None, xlabel: str = None,
265
- ylabel: str = None, zlabel: str = 'Entries/bin') -> None:
314
+ ylabel: str = None, zlabel: str = "Entries/bin") -> None:
266
315
  """Constructor.
267
316
  """
268
317
  super().__init__((xedges, yedges), label, [xlabel, ylabel, zlabel])
@@ -272,9 +321,9 @@ class Histogram2d(AbstractHistogram):
272
321
  """
273
322
  # pylint: disable=arguments-differ
274
323
  if logz:
275
- vmin = kwargs.pop('vmin', None)
276
- vmax = kwargs.pop('vmax', None)
277
- kwargs.setdefault('norm', matplotlib.colors.LogNorm(vmin, vmax))
324
+ vmin = kwargs.pop("vmin", None)
325
+ vmax = kwargs.pop("vmax", None)
326
+ kwargs.setdefault("norm", matplotlib.colors.LogNorm(vmin, vmax))
278
327
  mappable = axes.pcolormesh(*self._edges, self.content.T, **kwargs)
279
328
  plt.colorbar(mappable, ax=axes, label=self.axis_labels[2])
280
329
  setup_axes(axes, xlabel=self.axis_labels[0], ylabel=self.axis_labels[1])
@@ -23,17 +23,31 @@ from abc import ABC, abstractmethod
23
23
  from dataclasses import dataclass
24
24
  from itertools import chain
25
25
  from numbers import Number
26
- from typing import Callable, Iterator, Sequence, Tuple
26
+ from typing import Callable, Dict, Iterator, Sequence, Tuple
27
27
 
28
28
  import matplotlib.pyplot as plt
29
29
  import numpy as np
30
30
  import uncertainties
31
+ from scipy.integrate import quad
31
32
  from scipy.optimize import curve_fit
33
+ from scipy.special import erf
32
34
  from scipy.stats import chi2
33
35
 
34
36
  from .hist import Histogram1d
35
37
  from .typing_ import ArrayLike
36
38
 
39
+ __all__ = [
40
+ "Constant",
41
+ "Line",
42
+ "Quadratic",
43
+ "PowerLaw",
44
+ "Exponential",
45
+ "Gaussian",
46
+ "Erf",
47
+ "ErfInverse",
48
+ ]
49
+
50
+ # pylint: disable=too-many-lines
37
51
 
38
52
  class Format(str, enum.Enum):
39
53
 
@@ -277,6 +291,17 @@ class FitStatus:
277
291
  self.pvalue = None
278
292
  self.fit_range = None
279
293
 
294
+ def valid(self) -> bool:
295
+ """Return True if the fit status is valid, i.e., if the chisquare,
296
+ dof, and pvalue are all set.
297
+
298
+ Returns
299
+ -------
300
+ valid : bool
301
+ True if the fit status is valid.
302
+ """
303
+ return self.chisquare is not None and self.dof is not None and self.pvalue is not None
304
+
280
305
  def update(self, chisquare: float, dof: int = None) -> None:
281
306
  """Update the fit status, i.e., set the chisquare and calculate the
282
307
  corresponding p-value.
@@ -316,10 +341,10 @@ class FitStatus:
316
341
  if self.chisquare is None:
317
342
  return "N/A"
318
343
  if spec.endswith(Format.LATEX):
319
- return f"$\\chi^2$ = {self.chisquare:.2f} / {self.dof} dof"
344
+ return f"$\\chi^2$: {self.chisquare:.2f} / {self.dof} dof"
320
345
  if spec.endswith(Format.PRETTY):
321
- return f"χ² = {self.chisquare:.2f} / {self.dof} dof"
322
- return f"chisquare = {self.chisquare:.2f} / {self.dof} dof"
346
+ return f"χ²: {self.chisquare:.2f} / {self.dof} dof"
347
+ return f"chisquare: {self.chisquare:.2f} / {self.dof} dof"
323
348
 
324
349
  def __str__(self) -> str:
325
350
  """String formatting.
@@ -776,7 +801,7 @@ class AbstractFitModelBase(ABC):
776
801
  The formatted string.
777
802
  """
778
803
  text = f"{self.name()}\n"
779
- if self.status is not None:
804
+ if self.status.valid():
780
805
  text = f"{text}{format(self.status, spec)}\n"
781
806
  for parameter in self:
782
807
  text = f"{text}{format(parameter, spec)}\n"
@@ -807,13 +832,47 @@ class AbstractFitModel(AbstractFitModelBase):
807
832
  """
808
833
  super().__init__()
809
834
  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)
835
+ # Note we cannot loop over self.__dict__.items() here, as that would
836
+ # only return the members defined in the actual class, and not the
837
+ # inherited ones.
838
+ for name, value in self.__class__._parameter_dict().items():
839
+ parameter = value.copy(name)
840
+ # Note we also set one instance attribute for each parameter so
841
+ # that we can use the notation model.parameter
842
+ setattr(self, name, parameter)
843
+ self._parameters.append(parameter)
844
+
845
+ @classmethod
846
+ def _parameter_dict(cls) -> Dict[str, FitParameter]:
847
+ """Return a dictionary of all the FitParameter objects defined in the class
848
+ and its base classes.
849
+
850
+ This is a subtle one, as what we really want, here, is all members of a class
851
+ (including inherited ones) that are of a specific type (FitParameter), in the
852
+ order they were defined. All of these thing are instrumental to make the
853
+ fit model work, so we need to be careful.
854
+
855
+ Also note the we are looping over the MRO in reverse order, so that we
856
+ preserve the order of definition of the parameters, even when they are
857
+ inherited from base classes. If a parameter is re-defined in a derived class,
858
+ the derived class definition takes precedence, as we are using a dictionary
859
+ to collect the parameters.
860
+
861
+ Arguments
862
+ ---------
863
+ cls : type
864
+ The class to inspect.
865
+
866
+ Returns
867
+ -------
868
+ param_dict : dict
869
+ A dictionary mapping parameter names to their FitParameter objects.
870
+ """
871
+ param_dict = {}
872
+ for base in reversed(cls.__mro__):
873
+ param_dict.update({name: value for name, value in base.__dict__.items() if
874
+ isinstance(value, FitParameter)})
875
+ return param_dict
817
876
 
818
877
  def __len__(self) -> int:
819
878
  """Return the `total` number of fit parameters in the model.
@@ -832,6 +891,46 @@ class AbstractFitModel(AbstractFitModelBase):
832
891
  raise TypeError(f"{other} is not a fit model")
833
892
  return FitModelSum(self, other)
834
893
 
894
+ def quadrature(self, xmin: float, xmax: float) -> float:
895
+ """Calculate the integral of the model between xmin and xmax using
896
+ numerical integration.
897
+
898
+ Arguments
899
+ ---------
900
+ xmin : float
901
+ The minimum value of the independent variable to integrate over.
902
+
903
+ xmax : float
904
+ The maximum value of the independent variable to integrate over.
905
+
906
+ Returns
907
+ -------
908
+ integral : float
909
+ The integral of the model between xmin and xmax.
910
+ """
911
+ value, _ = quad(self, xmin, xmax)
912
+ return value
913
+
914
+ def integral(self, xmin: float, xmax: float) -> float:
915
+ """Default implementation of the integral of the model between xmin and xmax.
916
+ Subclasses can (and are encouraged to) overload this method with an
917
+ analytical implementation, when available.
918
+
919
+ Arguments
920
+ ---------
921
+ xmin : float
922
+ The minimum value of the independent variable to integrate over.
923
+
924
+ xmax : float
925
+ The maximum value of the independent variable to integrate over.
926
+
927
+ Returns
928
+ -------
929
+ integral : float
930
+ The integral of the model between xmin and xmax.
931
+ """
932
+ return self.quadrature(xmin, xmax)
933
+
835
934
 
836
935
  class FitModelSum(AbstractFitModelBase):
837
936
 
@@ -878,6 +977,26 @@ class FitModelSum(AbstractFitModelBase):
878
977
  cursor += len(component)
879
978
  return value
880
979
 
980
+ def integral(self, xmin: float, xmax: float) -> float:
981
+ """Calculate the integral of the model between xmin and xmax.
982
+
983
+ This is implemented as the sum of the integrals of the components.
984
+
985
+ Arguments
986
+ ---------
987
+ xmin : float
988
+ The minimum value of the independent variable to integrate over.
989
+
990
+ xmax : float
991
+ The maximum value of the independent variable to integrate over.
992
+
993
+ Returns
994
+ -------
995
+ integral : float
996
+ The integral of the model between xmin and xmax.
997
+ """
998
+ return sum(component.integral(xmin, xmax) for component in self._components)
999
+
881
1000
  def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) -> None:
882
1001
  """Overloaded method for plotting the model.
883
1002
  """
@@ -931,8 +1050,13 @@ class Constant(AbstractFitModel):
931
1050
  @staticmethod
932
1051
  def evaluate(x: ArrayLike, value: float) -> ArrayLike:
933
1052
  # pylint: disable=arguments-differ
1053
+ if isinstance(x, Number):
1054
+ return value
934
1055
  return np.full(x.shape, value)
935
1056
 
1057
+ def integral(self, xmin: float, xmax: float) -> float:
1058
+ return self.value.value * (xmax - xmin)
1059
+
936
1060
 
937
1061
  class Line(AbstractFitModel):
938
1062
 
@@ -947,6 +1071,29 @@ class Line(AbstractFitModel):
947
1071
  # pylint: disable=arguments-differ
948
1072
  return slope * x + intercept
949
1073
 
1074
+ def integral(self, xmin: float, xmax: float) -> float:
1075
+ slope, intercept = self.parameter_values()
1076
+ return 0.5 * slope * (xmax**2 - xmin**2) + intercept * (xmax - xmin)
1077
+
1078
+
1079
+ class Quadratic(AbstractFitModel):
1080
+
1081
+ """Quadratic model.
1082
+ """
1083
+
1084
+ a = FitParameter(1.)
1085
+ b = FitParameter(1.)
1086
+ c = FitParameter(0.)
1087
+
1088
+ @staticmethod
1089
+ def evaluate(x: ArrayLike, a: float, b: float, c: float) -> ArrayLike:
1090
+ # pylint: disable=arguments-differ
1091
+ return a * x**2 + b * x + c
1092
+
1093
+ def integral(self, xmin: float, xmax: float) -> float:
1094
+ a, b, c = self.parameter_values()
1095
+ return a * (xmax**3 - xmin**3) / 3. + b * (xmax**2 - xmin**2) / 2. + c * (xmax - xmin)
1096
+
950
1097
 
951
1098
  class PowerLaw(AbstractFitModel):
952
1099
 
@@ -954,33 +1101,88 @@ class PowerLaw(AbstractFitModel):
954
1101
  """
955
1102
 
956
1103
  prefactor = FitParameter(1.)
957
- index = FitParameter(-1.)
1104
+ index = FitParameter(-2.)
958
1105
 
959
1106
  @staticmethod
960
1107
  def evaluate(x: ArrayLike, prefactor: float, index: float) -> ArrayLike:
961
1108
  # pylint: disable=arguments-differ
962
1109
  return prefactor * x**index
963
1110
 
1111
+ def integral(self, xmin: float, xmax: float) -> float:
1112
+ prefactor, index = self.parameter_values()
1113
+ if index == -1.:
1114
+ return prefactor * np.log(xmax / xmin)
1115
+ return prefactor / (index + 1.) * (xmax**(index + 1.) - xmin**(index + 1.))
964
1116
 
965
- class Gaussian(AbstractFitModel):
1117
+ def default_plotting_range(self) -> Tuple[float, float]:
1118
+ return (0.1, 10.)
966
1119
 
967
- """Gaussian model.
1120
+ def plot(self, xmin: float = None, xmax: float = None, num_points: int = 200) -> None:
1121
+ super().plot(xmin, xmax, num_points)
1122
+ plt.xscale("log")
1123
+ plt.yscale("log")
1124
+
1125
+
1126
+ class Exponential(AbstractFitModel):
1127
+
1128
+ """Exponential model.
1129
+ """
1130
+
1131
+ prefactor = FitParameter(1.)
1132
+ scale = FitParameter(1.)
1133
+
1134
+ @staticmethod
1135
+ def evaluate(x: ArrayLike, prefactor: float, scale: float) -> ArrayLike:
1136
+ # pylint: disable=arguments-differ
1137
+ return prefactor * np.exp(-x / scale)
1138
+
1139
+ def integral(self, xmin: float, xmax: float) -> float:
1140
+ prefactor, scale = self.parameter_values()
1141
+ return prefactor * scale * (np.exp(-xmin / scale) - np.exp(-xmax / scale))
1142
+
1143
+ def default_plotting_range(self, scale_factor: int = 5) -> Tuple[float, float]:
1144
+ return (0., scale_factor * self.scale.value)
1145
+
1146
+
1147
+ class _GaussianBase(AbstractFitModel):
1148
+
1149
+ """Common base class for Gaussian-like models.
1150
+
1151
+ This provides a couple of convenience methods that are useful for all the
1152
+ models derived from a gaussian (e.g., the gaussian itself, the error function,
1153
+ and its inverse). Note that, for the right method to be picked up,
1154
+ subclasses should derive from this class *before* deriving from
1155
+ AbstractFitModel, so that the method resolution order (MRO) works as expected.
1156
+
1157
+ Note the evaluate() method is not implemented here, which means that the class
1158
+ cannot be instantiated directly.
968
1159
  """
969
1160
 
970
1161
  prefactor = FitParameter(1.)
971
1162
  mean = FitParameter(0.)
972
1163
  sigma = FitParameter(1., minimum=0.)
973
1164
 
1165
+ # A few useful constants.
1166
+ _SQRT2 = np.sqrt(2.)
974
1167
  _NORM_CONSTANT = 1. / np.sqrt(2. * np.pi)
975
1168
  _SIGMA_TO_FWHM = 2. * np.sqrt(2. * np.log(2.))
976
1169
 
977
- @staticmethod
978
- def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
979
- # pylint: disable=arguments-differ
980
- z = (x - mean) / sigma
981
- return prefactor * Gaussian._NORM_CONSTANT / sigma * np.exp(-0.5 * z**2.)
982
-
983
1170
  def default_plotting_range(self, num_sigma: int = 5) -> Tuple[float, float]:
1171
+ """Convenience function to return a default plotting range for all the
1172
+ models derived from a gaussian (e.g., the gaussian itself, the error
1173
+ function, and its inverse).
1174
+
1175
+ Arguments
1176
+ ---------
1177
+ num_sigma : int, optional
1178
+ The number of sigmas to use for the plotting range (default 5).
1179
+
1180
+ Returns
1181
+ -------
1182
+ Tuple[float, float]
1183
+ The default plotting range for the model.
1184
+ """
1185
+ # pylint: disable=no-member
984
1186
  mean, half_width = self.mean.value, num_sigma * self.sigma.value
985
1187
  return (mean - half_width, mean + half_width)
986
1188
 
@@ -992,4 +1194,46 @@ class Gaussian(AbstractFitModel):
992
1194
  fwhm : uncertainties.ufloat
993
1195
  The FWHM of the gaussian.
994
1196
  """
1197
+ # pylint: disable=no-member
995
1198
  return self.sigma.ufloat() * self._SIGMA_TO_FWHM
1199
+
1200
+
1201
+ class Gaussian(_GaussianBase):
1202
+
1203
+ """Gaussian model.
1204
+ """
1205
+
1206
+ @staticmethod
1207
+ def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
1208
+ # pylint: disable=arguments-differ
1209
+ z = (x - mean) / sigma
1210
+ return prefactor * _GaussianBase._NORM_CONSTANT / sigma * np.exp(-0.5 * z**2.)
1211
+
1212
+ def integral(self, xmin: float, xmax: float) -> float:
1213
+ prefactor, mean, sigma = self.parameter_values()
1214
+ zmin = (xmin - mean) / (sigma * self._SQRT2)
1215
+ zmax = (xmax - mean) / (sigma * self._SQRT2)
1216
+ return prefactor * 0.5 * (erf(zmax) - erf(zmin))
1217
+
1218
+
1219
+ class Erf(_GaussianBase):
1220
+
1221
+ """Error function model.
1222
+ """
1223
+
1224
+ @staticmethod
1225
+ def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
1226
+ # pylint: disable=arguments-differ
1227
+ z = (x - mean) / sigma
1228
+ return prefactor * 0.5 * (1. + erf(z / _GaussianBase._SQRT2))
1229
+
1230
+
1231
+ class ErfInverse(_GaussianBase):
1232
+
1233
+ """Inverse error function model.
1234
+ """
1235
+
1236
+ @staticmethod
1237
+ def evaluate(x: ArrayLike, prefactor: float, mean: float, sigma: float) -> ArrayLike:
1238
+ # pylint: disable=arguments-differ
1239
+ return prefactor - Erf.evaluate(x, prefactor, mean, sigma)
@@ -101,22 +101,43 @@ def test_plotting1d(size: int = 100000):
101
101
  plt.figure(inspect.currentframe().f_code.co_name)
102
102
  # Create the first histogram. This has no label attached, so we will have to
103
103
  # provide one at plotting time, if we want to have a corresponding legend entry.
104
+ mean = 0.
105
+ sigma = 1.
104
106
  hist1 = Histogram1d(np.linspace(-5., 5., 100), xlabel='x')
105
- hist1.fill(_RNG.normal(size=size))
107
+ hist1.fill(_RNG.normal(size=size, loc=mean, scale=sigma))
106
108
  hist1.plot(label='Standard histogram')
109
+ m, s = hist1.binned_statistics()
110
+ # Rough checks on the binned statistics---we want the mean to be within 10
111
+ # sigma/sqrt(N) and the stddev to be within 2% of the true value.
112
+ # (Note the binning has an effect on the actual values, so we cannot
113
+ # expect perfect agreement.)
114
+ assert abs((m - mean) / sigma * np.sqrt(size)) < 10.
115
+ assert abs(s / sigma - 1.) < 0.02
116
+
107
117
  # Create a second histogram, this time with a label---this should have a
108
118
  # proper entry in the legend automatically.
119
+ mean = 1.
120
+ sigma = 1.5
109
121
  hist2 = Histogram1d(np.linspace(-5., 5., 100), label='Offset histogram')
110
- hist2.fill(_RNG.normal(size=size, loc=1.))
122
+ hist2.fill(_RNG.normal(size=size, loc=mean, scale=sigma))
111
123
  hist2.plot()
124
+ m, s = hist2.binned_statistics()
125
+ assert abs((m - mean) / sigma * np.sqrt(size)) < 10.
126
+ assert abs(s / sigma - 1.) < 0.02
127
+
112
128
  # And this one should end up with no legend entry, as it has no label
129
+ mean = -1.
130
+ sigma = 0.5
113
131
  hist3 = Histogram1d(np.linspace(-5., 5., 100))
114
- hist3.fill(_RNG.normal(size=size // 2, loc=-1.))
132
+ hist3.fill(_RNG.normal(size=size, loc=mean, scale=sigma))
115
133
  hist3.plot()
134
+ m, s = hist3.binned_statistics()
135
+ assert abs((m - mean) / sigma * np.sqrt(size)) < 10.
136
+ assert abs(s / sigma - 1.) < 0.02
116
137
  plt.legend()
117
138
 
118
139
 
119
- def test_plotting2d(size: int = 100000):
140
+ def test_plotting2d(size: int = 100000, x0: float = 1., y0: float = -1.):
120
141
  """Test plotting.
121
142
  """
122
143
  plt.figure(inspect.currentframe().f_code.co_name)
@@ -124,8 +145,14 @@ def test_plotting2d(size: int = 100000):
124
145
  hist = Histogram2d(edges, edges, 'x', 'y')
125
146
  # Note we are adding different offsets to x and y so that we can see
126
147
  # the effect on the plot.
127
- hist.fill(_RNG.normal(size=size) + 1., _RNG.normal(size=size) - 1.)
148
+ hist.fill(_RNG.normal(size=size, loc=x0), _RNG.normal(size=size, loc=y0))
128
149
  hist.plot()
150
+ mx, sx = hist.binned_statistics(0)
151
+ my, sy = hist.binned_statistics(1)
152
+ assert abs((mx - x0) * np.sqrt(size)) < 10.
153
+ assert abs((my - y0) * np.sqrt(size)) < 10.
154
+ assert abs(sx - 1.) < 0.02
155
+ assert abs(sy - 1.) < 0.02
129
156
  plt.gca().set_aspect('equal')
130
157
 
131
158
 
@@ -19,14 +19,25 @@
19
19
  import inspect
20
20
 
21
21
  import numpy as np
22
+ import pytest
22
23
 
23
24
  from aptapy.hist import Histogram1d
24
- from aptapy.modeling import Constant, FitParameter, Gaussian, Line
25
+ from aptapy.modeling import (
26
+ Constant,
27
+ Erf,
28
+ ErfInverse,
29
+ Exponential,
30
+ FitParameter,
31
+ Gaussian,
32
+ Line,
33
+ PowerLaw,
34
+ Quadratic,
35
+ )
25
36
  from aptapy.plotting import plt
26
37
 
27
38
  _RNG = np.random.default_rng(313)
28
39
 
29
- TEST_HISTOGRAM = Histogram1d(np.linspace(-5., 5., 100), label="Test data")
40
+ TEST_HISTOGRAM = Histogram1d(np.linspace(-5., 5., 100), label="Random data")
30
41
  TEST_HISTOGRAM.fill(_RNG.normal(size=100000))
31
42
  NUM_SIGMA = 4.
32
43
 
@@ -82,6 +93,89 @@ def test_model_parameters():
82
93
  assert id(p1) != id(p2)
83
94
 
84
95
 
96
+ def test_plot():
97
+ """Test the plot method of the models.
98
+ """
99
+ for model in (Constant(), Line(), Quadratic(), PowerLaw(), Exponential(),
100
+ Gaussian(), Erf(), ErfInverse()):
101
+ plt.figure(f"{inspect.currentframe().f_code.co_name}_{model.__class__.__name__}")
102
+ model.plot()
103
+ plt.legend()
104
+
105
+
106
+ def test_integral():
107
+ """Test the integral method of the models.
108
+ """
109
+ # pylint: disable=too-many-statements
110
+ # Constant.
111
+ xmin = 0.
112
+ xmax = 1.
113
+ value = 1.
114
+ target = value * (xmax - xmin)
115
+ model = Constant()
116
+ model.value.freeze(1.)
117
+ assert model.quadrature(xmin, xmax) == pytest.approx(target)
118
+ assert model.integral(xmin, xmax) == pytest.approx(target)
119
+ # Line.
120
+ slope = 1.
121
+ intercept = 1.
122
+ target = 0.5 * slope * (xmax**2 - xmin**2) + intercept * (xmax - xmin)
123
+ model = Line()
124
+ model.slope.freeze(slope)
125
+ model.intercept.freeze(intercept)
126
+ assert model.quadrature(xmin, xmax) == pytest.approx(target)
127
+ assert model.integral(xmin, xmax) == pytest.approx(target)
128
+ # Quadratic.
129
+ a = 1.
130
+ b = 1.
131
+ c = 1.
132
+ target = a * (xmax**3 - xmin**3) / 3. + b * (xmax**2 - xmin**2) / 2. + c * (xmax - xmin)
133
+ model = Quadratic()
134
+ model.a.freeze(a)
135
+ model.b.freeze(b)
136
+ model.c.freeze(c)
137
+ assert model.quadrature(xmin, xmax) == pytest.approx(target)
138
+ assert model.integral(xmin, xmax) == pytest.approx(target)
139
+ # PowerLaw.
140
+ xmin = 1.
141
+ xmax = 10.
142
+ prefactor = 1.
143
+ for index in (-2., -1.):
144
+ if index == -1.:
145
+ target = prefactor * np.log(xmax / xmin)
146
+ else:
147
+ target = prefactor / (index + 1.) * (xmax**(index + 1.) - xmin**(index + 1.))
148
+ model = PowerLaw()
149
+ model.prefactor.freeze(prefactor)
150
+ model.index.freeze(index)
151
+ assert model.quadrature(xmin, xmax) == pytest.approx(target)
152
+ assert model.integral(xmin, xmax) == pytest.approx(target)
153
+ # Exponential.
154
+ xmin = 0.
155
+ xmax = 10.
156
+ prefactor = 1.
157
+ scale = 1.
158
+ target = prefactor * scale * (np.exp(-xmin / scale) - np.exp(-xmax / scale))
159
+ model = Exponential()
160
+ model.prefactor.freeze(prefactor)
161
+ model.scale.freeze(scale)
162
+ assert model.quadrature(xmin, xmax) == pytest.approx(target)
163
+ assert model.integral(xmin, xmax) == pytest.approx(target)
164
+ # Gaussian.
165
+ xmin = -5.
166
+ xmax = 5.
167
+ prefactor = 1.
168
+ mean = 0.
169
+ sigma = 1.
170
+ target = 1.
171
+ model = Gaussian()
172
+ model.prefactor.freeze(prefactor)
173
+ model.mean.freeze(mean)
174
+ model.sigma.freeze(sigma)
175
+ assert model.quadrature(xmin, xmax) == pytest.approx(target)
176
+ assert model.integral(xmin, xmax) == pytest.approx(target)
177
+
178
+
85
179
  def test_gaussian_fit():
86
180
  """Simple Gaussian fit.
87
181
  """
@@ -181,6 +275,7 @@ def test_multiple_sum():
181
275
 
182
276
 
183
277
  if __name__ == '__main__':
278
+ test_plot()
184
279
  test_gaussian_fit()
185
280
  test_gaussian_fit_subrange()
186
281
  test_gaussian_fit_bound()
@@ -1 +0,0 @@
1
- __version__ = "0.3.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes