aptapy 0.1.1__tar.gz → 0.2.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 (34) hide show
  1. {aptapy-0.1.1 → aptapy-0.2.0}/PKG-INFO +1 -1
  2. aptapy-0.2.0/docs/hist.rst +45 -0
  3. {aptapy-0.1.1 → aptapy-0.2.0}/docs/index.rst +1 -0
  4. aptapy-0.2.0/docs/release_notes.rst +20 -0
  5. aptapy-0.2.0/src/aptapy/_version.py +1 -0
  6. aptapy-0.2.0/src/aptapy/hist.py +226 -0
  7. {aptapy-0.1.1 → aptapy-0.2.0}/src/aptapy/modeling.py +22 -22
  8. aptapy-0.2.0/src/aptapy/plotting.py +523 -0
  9. aptapy-0.2.0/tests/test_hist.py +123 -0
  10. aptapy-0.1.1/docs/release_notes.rst +0 -12
  11. aptapy-0.1.1/src/aptapy/_version.py +0 -1
  12. aptapy-0.1.1/src/aptapy/plotting.py +0 -486
  13. {aptapy-0.1.1 → aptapy-0.2.0}/.github/workflows/ci.yml +0 -0
  14. {aptapy-0.1.1 → aptapy-0.2.0}/.github/workflows/docs.yml +0 -0
  15. {aptapy-0.1.1 → aptapy-0.2.0}/.github/workflows/pypi.yml +0 -0
  16. {aptapy-0.1.1 → aptapy-0.2.0}/.gitignore +0 -0
  17. {aptapy-0.1.1 → aptapy-0.2.0}/CODE_OF_CONDUCT.md +0 -0
  18. {aptapy-0.1.1 → aptapy-0.2.0}/CONTRIBUTING.md +0 -0
  19. {aptapy-0.1.1 → aptapy-0.2.0}/LICENSE +0 -0
  20. {aptapy-0.1.1 → aptapy-0.2.0}/README.md +0 -0
  21. {aptapy-0.1.1 → aptapy-0.2.0}/docs/Makefile +0 -0
  22. {aptapy-0.1.1 → aptapy-0.2.0}/docs/_static/favicon.ico +0 -0
  23. {aptapy-0.1.1 → aptapy-0.2.0}/docs/_static/logo.png +0 -0
  24. {aptapy-0.1.1 → aptapy-0.2.0}/docs/_static/logo_small.png +0 -0
  25. {aptapy-0.1.1 → aptapy-0.2.0}/docs/conf.py +0 -0
  26. {aptapy-0.1.1 → aptapy-0.2.0}/docs/make.bat +0 -0
  27. {aptapy-0.1.1 → aptapy-0.2.0}/docs/modeling.rst +0 -0
  28. {aptapy-0.1.1 → aptapy-0.2.0}/noxfile.py +0 -0
  29. {aptapy-0.1.1 → aptapy-0.2.0}/pyproject.toml +0 -0
  30. {aptapy-0.1.1 → aptapy-0.2.0}/src/aptapy/__init__.py +0 -0
  31. {aptapy-0.1.1 → aptapy-0.2.0}/src/aptapy/py.typed +0 -0
  32. {aptapy-0.1.1 → aptapy-0.2.0}/src/aptapy/typing_.py +0 -0
  33. {aptapy-0.1.1 → aptapy-0.2.0}/tests/test_modeling.py +0 -0
  34. {aptapy-0.1.1 → aptapy-0.2.0}/tools/release.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aptapy
3
- Version: 0.1.1
3
+ Version: 0.2.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
@@ -0,0 +1,45 @@
1
+ .. _hist:
2
+
3
+ :mod:`~aptapy.hist` --- Histograms
4
+ ==================================
5
+
6
+ The module provides an abstract base class for n-dimensional histograms along
7
+ with concrete implementations for 1D and 2D histograms:
8
+
9
+ * :class:`~aptapy.hist.AbstractHistogram`: Abstract base class for histograms;
10
+ * :class:`~aptapy.hist.Histogram1d`: 1D histogram implementation;
11
+ * :class:`~aptapy.hist.Histogram2d`: 2D histogram implementation.
12
+
13
+ Histograms are constructed with the bin edges (and, optionally, labels to be
14
+ used at the plotting stage) and are filled using the
15
+ :meth:`~aptapy.hist.AbstractHistogram.fill` method. The basic semantics is as
16
+ follows:
17
+
18
+ .. code-block:: python
19
+
20
+ import numpy as np
21
+ from aptapy.hist import Histogram1d, Histogram2d
22
+
23
+ rng = np.random.default_rng()
24
+ edges = np.linspace(-5., 5., 100)
25
+
26
+ hist = Histogram1d(edges, "x")
27
+ hist.fill(rng.normal(size=1000))
28
+ hist.plot()
29
+
30
+ hist = Histogram2d(edges, edges, 'x', 'y')
31
+ hist.fill(rng.normal(size=1000), rng.normal(size=1000))
32
+ hist.plot()
33
+
34
+ Histograms support weighted filling and basic arithmetic operations (addition
35
+ and subtraction) between histograms with identical binning.
36
+
37
+ .. warning::
38
+
39
+ Multiplication by a scalar is not yet supported.
40
+
41
+
42
+ Module documentation
43
+ --------------------
44
+
45
+ .. automodule:: aptapy.hist
@@ -12,5 +12,6 @@ Statistical tools for online monitoring and analysis.
12
12
  :maxdepth: 2
13
13
  :caption: Contents:
14
14
 
15
+ hist
15
16
  modeling
16
17
  release_notes
@@ -0,0 +1,20 @@
1
+ .. _release_notes:
2
+
3
+ Release notes
4
+ =============
5
+
6
+
7
+ Version 0.2.0 (2025-10-06)
8
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
9
+
10
+ * New histogram facilities added.
11
+
12
+ * Pull requests merged:
13
+
14
+ - https://github.com/lucabaldini/aptapy/pull/2
15
+
16
+
17
+ Version 0.1.1 (2025-10-03)
18
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
19
+
20
+ Initial release on PyPI.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,226 @@
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
+ """Histogram facilities.
17
+ """
18
+
19
+ from abc import ABC, abstractmethod
20
+ from typing import List, Sequence
21
+
22
+ import numpy as np
23
+
24
+ from .plotting import matplotlib, plt, setup_axes
25
+ from .typing_ import ArrayLike
26
+
27
+
28
+ class AbstractHistogram(ABC):
29
+
30
+ """Abstract base class for an n-dimensional histogram.
31
+
32
+ Arguments
33
+ ---------
34
+ edges : n-dimensional sequence of arrays
35
+ the bin edges on the different axes.
36
+
37
+ labels : sequence of strings
38
+ the text labels for the different axes.
39
+ """
40
+
41
+ DEFAULT_PLOT_OPTIONS = {}
42
+
43
+ def __init__(self, edges: Sequence[np.ndarray], labels: List[str]) -> None:
44
+ """Constructor.
45
+ """
46
+ # Edges are fixed once and forever, so we create a copy. Also, no matter
47
+ # which kind of sequence we are passing, we turn the thing into a tuple.
48
+ self._edges = tuple(np.asarray(item, dtype=float).copy() for item in edges)
49
+ self._num_axes = len(self._edges)
50
+
51
+ # And a few basic checks on the input arguments.
52
+ for item in self._edges:
53
+ if item.ndim != 1:
54
+ raise ValueError(f"Bin edges {item} are not a 1-dimensional array.")
55
+ if item.size < 2:
56
+ raise ValueError(f"Bin edges {item} have less than 2 entries.")
57
+ if np.any(np.diff(item) <= 0):
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.")
61
+
62
+ # Go ahead and create all the necessary data structures.
63
+ self._shape = tuple(item.size - 1 for item in self._edges)
64
+ self._sumw = np.zeros(self._shape, dtype=float)
65
+ self._sumw2 = np.zeros(self._shape, dtype=float)
66
+ self._labels = labels
67
+
68
+ @property
69
+ def content(self) -> np.ndarray:
70
+ """Return the bin contents.
71
+ """
72
+ return self._sumw
73
+
74
+ @property
75
+ def errors(self) -> np.ndarray:
76
+ """Return the bin errors.
77
+ """
78
+ return np.sqrt(self._sumw2)
79
+
80
+ def bin_edges(self, axis: int = 0) -> np.ndarray:
81
+ """Return a view on the binning for specific axis.
82
+ """
83
+ return self._edges[axis].view()
84
+
85
+ def bin_centers(self, axis: int = 0) -> np.ndarray:
86
+ """Return the bin centers for a specific axis.
87
+ """
88
+ return 0.5 * (self._edges[axis][1:] + self._edges[axis][:-1])
89
+
90
+ def bin_widths(self, axis: int = 0) -> np.ndarray:
91
+ """Return the bin widths for a specific axis.
92
+ """
93
+ return np.diff(self._edges[axis])
94
+
95
+ def fill(self, *values: ArrayLike, weights: ArrayLike = None) -> "AbstractHistogram":
96
+ """Fill the histogram from unbinned data.
97
+
98
+ Note this method is returning the histogram instance, so that the function
99
+ call can be chained.
100
+ """
101
+ values = np.vstack(values).T
102
+ sumw, _ = np.histogramdd(values, bins=self._edges, weights=weights)
103
+ if weights is None:
104
+ sumw2 = sumw
105
+ else:
106
+ sumw2, _ = np.histogramdd(values, bins=self._edges, weights=weights**2.)
107
+ self._sumw += sumw
108
+ self._sumw2 += sumw2
109
+ return self
110
+
111
+ def copy(self) -> "AbstractHistogram":
112
+ """Create a full copy of a histogram.
113
+ """
114
+ # pylint: disable=protected-access
115
+ # Note we really need the * in the constructor, here, as the abstract
116
+ # base class is never instantiated, and the arguments are unpacked in the
117
+ # constructors of all the derived classes.
118
+ histogram = self.__class__(*self._edges, *self._labels)
119
+ histogram._sumw = self._sumw.copy()
120
+ histogram._sumw2 = self._sumw2.copy()
121
+ return histogram
122
+
123
+ def _check_compat(self, other: "AbstractHistogram") -> None:
124
+ """Check whether two histogram objects are compatible with each other,
125
+ meaning, e.g., that they can be summed or subtracted.
126
+ """
127
+ # pylint: disable=protected-access
128
+ if not isinstance(other, AbstractHistogram):
129
+ raise TypeError(f"{other} is not a histogram.")
130
+ if self._num_axes != other._num_axes or self._shape != other._shape:
131
+ raise ValueError("Histogram dimensionality/shape mismatch.")
132
+ for edges in zip(self._edges, other._edges):
133
+ if not np.allclose(*edges):
134
+ raise ValueError("Histogram bin edges differ.")
135
+
136
+ def __iadd__(self, other: "AbstractHistogram") -> "AbstractHistogram":
137
+ """Histogram addition (in place).
138
+ """
139
+ self._check_compat(other)
140
+ self._sumw += other._sumw
141
+ self._sumw2 += other._sumw2
142
+ return self
143
+
144
+ def __add__(self, other: "AbstractHistogram") -> "AbstractHistogram":
145
+ """Histogram addition.
146
+ """
147
+ histogram = self.copy()
148
+ histogram += other
149
+ return histogram
150
+
151
+ def __isub__(self, other: "AbstractHistogram") -> "AbstractHistogram":
152
+ """Histogram subtraction (in place).
153
+ """
154
+ self._check_compat(other)
155
+ self._sumw -= other._sumw
156
+ self._sumw2 += other._sumw2
157
+ return self
158
+
159
+ def __sub__(self, other: "AbstractHistogram") -> "AbstractHistogram":
160
+ """Histogram subtraction.
161
+ """
162
+ histogram = self.copy()
163
+ histogram -= other
164
+ return histogram
165
+
166
+ @abstractmethod
167
+ def _do_plot(self, axes, **kwargs) -> None:
168
+ pass
169
+
170
+ def plot(self, axes=None, **kwargs) -> None:
171
+ """Plot the histogram.
172
+ """
173
+ if axes is None:
174
+ axes = plt.gca()
175
+ for key, value in self.DEFAULT_PLOT_OPTIONS.items():
176
+ kwargs.setdefault(key, value)
177
+ self._do_plot(axes, **kwargs)
178
+
179
+ def __repr__(self) -> str:
180
+ """String representation of the histogram.
181
+ """
182
+ return f"{self.__class__.__name__}({self._num_axes} axes, shape={self._shape})"
183
+
184
+
185
+ class Histogram1d(AbstractHistogram):
186
+
187
+ """One-dimensional histogram.
188
+ """
189
+
190
+ DEFAULT_PLOT_OPTIONS = dict(linewidth=1.25, alpha=0.4, histtype="stepfilled")
191
+
192
+ def __init__(self, xedges: np.ndarray, xlabel: str = "", ylabel: str = "Entries/bin") -> None:
193
+ """Constructor.
194
+ """
195
+ super().__init__((xedges, ), [xlabel, ylabel])
196
+
197
+ def _do_plot(self, axes: matplotlib.axes._axes.Axes, **kwargs) -> None:
198
+ """Overloaded make_plot() method.
199
+ """
200
+ 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])
202
+
203
+
204
+ class Histogram2d(AbstractHistogram):
205
+
206
+ """Two-dimensional histogram.
207
+ """
208
+
209
+ DEFAULT_PLOT_OPTIONS = dict(cmap=plt.get_cmap('hot'))
210
+
211
+ def __init__(self, xedges, yedges, xlabel='', ylabel='', zlabel='Entries/bin') -> None:
212
+ """Constructor.
213
+ """
214
+ super().__init__((xedges, yedges), [xlabel, ylabel, zlabel])
215
+
216
+ def _do_plot(self, axes: matplotlib.axes._axes.Axes, logz: bool = False, **kwargs) -> None:
217
+ """Overloaded make_plot() method.
218
+ """
219
+ # pylint: disable=arguments-differ
220
+ if logz:
221
+ vmin = kwargs.pop('vmin', None)
222
+ vmax = kwargs.pop('vmax', None)
223
+ kwargs.setdefault('norm', matplotlib.colors.LogNorm(vmin, vmax))
224
+ 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])
@@ -37,11 +37,11 @@ class Format(str, enum.Enum):
37
37
  """Small enum class to control string formatting.
38
38
 
39
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.
40
+ a trailing `P` means "pretty print" and a trailing `L` means "LaTeX".
41
41
  """
42
42
 
43
- PRETTY = 'P'
44
- LATEX = 'L'
43
+ PRETTY = "P"
44
+ LATEX = "L"
45
45
 
46
46
 
47
47
  @dataclass
@@ -80,7 +80,7 @@ class FitParameter:
80
80
  """
81
81
  return not np.isinf(self.minimum) or not np.isinf(self.maximum)
82
82
 
83
- def copy(self, name: str) -> 'FitParameter':
83
+ def copy(self, name: str) -> "FitParameter":
84
84
  """Create a copy of the parameter object with a new name.
85
85
 
86
86
  This is necessary because we define the fit parameters of the actual model as
@@ -139,24 +139,24 @@ class FitParameter:
139
139
  """String formatting.
140
140
  """
141
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.
142
+ # f"{parameter}", so we can't really assign a default value to spec.
143
143
  if self.error is not None:
144
144
  param = format(self.ufloat(), spec)
145
145
  if spec.endswith(Format.LATEX):
146
- param = f'${param}$'
146
+ param = f"${param}$"
147
147
  else:
148
148
  spec = spec.rstrip(Format.PRETTY).rstrip(Format.LATEX)
149
149
  param = format(self.value, spec)
150
- text = f'{self._name.title()}: {param}'
150
+ text = f"{self._name.title()}: {param}"
151
151
  info = []
152
152
  if self._frozen:
153
- info.append('frozen')
153
+ info.append("frozen")
154
154
  if not np.isinf(self.minimum):
155
- info.append(f'min={self.minimum}')
155
+ info.append(f"min={self.minimum}")
156
156
  if not np.isinf(self.maximum):
157
- info.append(f'max={self.maximum}')
157
+ info.append(f"max={self.maximum}")
158
158
  if info:
159
- text = f'{text} ({", ".join(info)})'
159
+ text = f"{text} ({', '.join(info)})"
160
160
  return text
161
161
 
162
162
  def __str__(self) -> str:
@@ -191,12 +191,12 @@ class FitStatus:
191
191
  """String formatting.
192
192
  """
193
193
  if self.chisquare is None:
194
- return 'N/A'
194
+ return "N/A"
195
195
  if spec.endswith(Format.LATEX):
196
- return f'$\\chi^2$ = {self.chisquare:.2f} / {self.dof} dof'
196
+ return f"$\\chi^2$ = {self.chisquare:.2f} / {self.dof} dof"
197
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'
198
+ return f"χ² = {self.chisquare:.2f} / {self.dof} dof"
199
+ return f"chisquare = {self.chisquare:.2f} / {self.dof} dof"
200
200
 
201
201
  def __str__(self) -> str:
202
202
  """String formatting.
@@ -337,7 +337,7 @@ class AbstractFitModel(ABC):
337
337
  # print out an error message.
338
338
  unknown_parameter_names = set(constraints) - set(parameter_names)
339
339
  if unknown_parameter_names:
340
- raise ValueError(f'Cannot freeze unknown parameters {unknown_parameter_names}')
340
+ raise ValueError(f"Cannot freeze unknown parameters {unknown_parameter_names}")
341
341
 
342
342
  # Now we need to build the signature for the new function, starting from a
343
343
  # clean copy of the parameter for the independent variable...
@@ -353,8 +353,8 @@ class AbstractFitModel(ABC):
353
353
  @functools.wraps(model_function)
354
354
  def wrapper(x, *args):
355
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})')
356
+ raise TypeError(f"Frozen wrapper got {len(args)} parameters instead of " \
357
+ f"{num_free_parameters} ({free_parameter_names})")
358
358
  parameter_dict = {**dict(zip(free_parameter_names, args)), **constraints}
359
359
  return model_function(x, *[parameter_dict[name] for name in parameter_names])
360
360
 
@@ -379,7 +379,7 @@ class AbstractFitModel(ABC):
379
379
  # (And, since we are at it, make sure we have enough degrees of freedom.)
380
380
  self.status.dof = int(mask.sum() - len(self))
381
381
  if self.status.dof < 0:
382
- raise RuntimeError(f'{self.name()} has no degrees of freedom')
382
+ raise RuntimeError(f"{self.name()} has no degrees of freedom")
383
383
  xdata = xdata[mask]
384
384
  ydata = ydata[mask]
385
385
  if not isinstance(sigma, Number):
@@ -444,10 +444,10 @@ class AbstractFitModel(ABC):
444
444
  def __format__(self, spec: str) -> str:
445
445
  """String formatting.
446
446
  """
447
- text = f'{self.__class__.__name__} ({format(self.status, spec)})\n'
447
+ text = f"{self.__class__.__name__} ({format(self.status, spec)})\n"
448
448
  for parameter in self._parameters:
449
- text = f'{text}{format(parameter, spec)}\n'
450
- return text.strip('\n')
449
+ text = f"{text}{format(parameter, spec)}\n"
450
+ return text.strip("\n")
451
451
 
452
452
  def __str__(self):
453
453
  """String formatting.