rootfig 0.1.0__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 (106) hide show
  1. {rootfig-0.1.0 → rootfig-0.2.0}/.gitignore +1 -0
  2. {rootfig-0.1.0 → rootfig-0.2.0}/CONTRIBUTING.md +9 -8
  3. {rootfig-0.1.0 → rootfig-0.2.0}/PKG-INFO +6 -7
  4. {rootfig-0.1.0 → rootfig-0.2.0}/docs/composable.md +8 -3
  5. {rootfig-0.1.0 → rootfig-0.2.0}/docs/ecosystem.md +4 -1
  6. {rootfig-0.1.0 → rootfig-0.2.0}/docs/expressions.md +5 -3
  7. rootfig-0.2.0/docs/images/gallery/arrays.png +0 -0
  8. rootfig-0.2.0/docs/images/gallery/cms_density.png +0 -0
  9. rootfig-0.2.0/docs/images/gallery/correlation.png +0 -0
  10. rootfig-0.2.0/docs/images/gallery/efficiency.png +0 -0
  11. rootfig-0.2.0/docs/images/gallery/expressions.png +0 -0
  12. rootfig-0.2.0/docs/images/gallery/fill_stats.png +0 -0
  13. rootfig-0.2.0/docs/images/gallery/hist2d.png +0 -0
  14. rootfig-0.2.0/docs/images/gallery/log_axes.png +0 -0
  15. rootfig-0.2.0/docs/images/gallery/luminosity.png +0 -0
  16. rootfig-0.2.0/docs/images/gallery/many_plots.png +0 -0
  17. rootfig-0.2.0/docs/images/gallery/object_vs_event.png +0 -0
  18. rootfig-0.2.0/docs/images/gallery/overlay_ratio.png +0 -0
  19. rootfig-0.2.0/docs/images/gallery/profile.png +0 -0
  20. rootfig-0.2.0/docs/images/gallery/quick.png +0 -0
  21. rootfig-0.2.0/docs/images/gallery/ratio_reference.png +0 -0
  22. rootfig-0.2.0/docs/images/gallery/robust_range.png +0 -0
  23. rootfig-0.2.0/docs/images/gallery/stack_data.png +0 -0
  24. rootfig-0.2.0/docs/images/gallery/style_colors.png +0 -0
  25. rootfig-0.2.0/docs/images/gallery/variable_bins.png +0 -0
  26. rootfig-0.2.0/docs/images/gallery/xbreak_ratio.png +0 -0
  27. {rootfig-0.1.0 → rootfig-0.2.0}/docs/plotting.md +33 -6
  28. {rootfig-0.1.0 → rootfig-0.2.0}/pyproject.toml +9 -7
  29. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/__init__.py +1 -1
  30. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/api.py +63 -23
  31. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/expressions/parser.py +4 -1
  32. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/histograms/__init__.py +2 -0
  33. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/histograms/build.py +66 -15
  34. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/histograms/cutflow.py +14 -7
  35. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/histograms/efficiency.py +20 -12
  36. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/histograms/normalize.py +59 -21
  37. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/histograms/pipeline.py +50 -8
  38. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/histograms/stats.py +16 -4
  39. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/io/sources.py +26 -1
  40. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/model/__init__.py +1 -2
  41. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/model/binning.py +7 -2
  42. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/model/samples.py +65 -22
  43. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/model/variables.py +14 -1
  44. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/plotting/__init__.py +4 -0
  45. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/plotting/correlation.py +4 -1
  46. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/plotting/figure.py +26 -1
  47. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/plotting/hist1d.py +1 -1
  48. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/plotting/hist2d.py +15 -4
  49. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/plotting/result.py +9 -7
  50. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/plotting/style.py +106 -21
  51. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/selection/columns.py +20 -3
  52. {rootfig-0.1.0 → rootfig-0.2.0}/tests/test_api.py +227 -2
  53. {rootfig-0.1.0 → rootfig-0.2.0}/tests/test_gallery.py +1 -1
  54. {rootfig-0.1.0 → rootfig-0.2.0}/tests/test_histograms.py +209 -1
  55. {rootfig-0.1.0 → rootfig-0.2.0}/tests/test_io.py +14 -0
  56. {rootfig-0.1.0 → rootfig-0.2.0}/tests/test_model.py +52 -4
  57. {rootfig-0.1.0 → rootfig-0.2.0}/tests/test_plotting.py +10 -1
  58. {rootfig-0.1.0 → rootfig-0.2.0}/tests/test_selection.py +24 -0
  59. rootfig-0.1.0/CHANGELOG.md +0 -87
  60. rootfig-0.1.0/docs/images/gallery/arrays.png +0 -0
  61. rootfig-0.1.0/docs/images/gallery/cms_density.png +0 -0
  62. rootfig-0.1.0/docs/images/gallery/correlation.png +0 -0
  63. rootfig-0.1.0/docs/images/gallery/efficiency.png +0 -0
  64. rootfig-0.1.0/docs/images/gallery/expressions.png +0 -0
  65. rootfig-0.1.0/docs/images/gallery/fill_stats.png +0 -0
  66. rootfig-0.1.0/docs/images/gallery/hist2d.png +0 -0
  67. rootfig-0.1.0/docs/images/gallery/log_axes.png +0 -0
  68. rootfig-0.1.0/docs/images/gallery/luminosity.png +0 -0
  69. rootfig-0.1.0/docs/images/gallery/many_plots.png +0 -0
  70. rootfig-0.1.0/docs/images/gallery/object_vs_event.png +0 -0
  71. rootfig-0.1.0/docs/images/gallery/overlay_ratio.png +0 -0
  72. rootfig-0.1.0/docs/images/gallery/profile.png +0 -0
  73. rootfig-0.1.0/docs/images/gallery/quick.png +0 -0
  74. rootfig-0.1.0/docs/images/gallery/ratio_reference.png +0 -0
  75. rootfig-0.1.0/docs/images/gallery/robust_range.png +0 -0
  76. rootfig-0.1.0/docs/images/gallery/stack_data.png +0 -0
  77. rootfig-0.1.0/docs/images/gallery/style_colors.png +0 -0
  78. rootfig-0.1.0/docs/images/gallery/variable_bins.png +0 -0
  79. rootfig-0.1.0/docs/images/gallery/xbreak_ratio.png +0 -0
  80. {rootfig-0.1.0 → rootfig-0.2.0}/LICENSE +0 -0
  81. {rootfig-0.1.0 → rootfig-0.2.0}/README.md +0 -0
  82. {rootfig-0.1.0 → rootfig-0.2.0}/docs/api.md +0 -0
  83. {rootfig-0.1.0 → rootfig-0.2.0}/docs/gallery.md +0 -0
  84. {rootfig-0.1.0 → rootfig-0.2.0}/docs/hooks/gallery.py +0 -0
  85. {rootfig-0.1.0 → rootfig-0.2.0}/docs/index.md +0 -0
  86. {rootfig-0.1.0 → rootfig-0.2.0}/docs/quickstart.md +0 -0
  87. {rootfig-0.1.0 → rootfig-0.2.0}/examples/gallery.py +0 -0
  88. {rootfig-0.1.0 → rootfig-0.2.0}/mkdocs.yml +0 -0
  89. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/_typing.py +0 -0
  90. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/errors.py +0 -0
  91. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/expressions/__init__.py +0 -0
  92. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/expressions/functions.py +0 -0
  93. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/histograms/ratio.py +0 -0
  94. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/io/__init__.py +0 -0
  95. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/model/cuts.py +0 -0
  96. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/model/style.py +0 -0
  97. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/model/units.py +0 -0
  98. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/plotting/annotations.py +0 -0
  99. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/plotting/points.py +0 -0
  100. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/plotting/ratio.py +0 -0
  101. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/py.typed +0 -0
  102. {rootfig-0.1.0 → rootfig-0.2.0}/src/rootfig/selection/__init__.py +0 -0
  103. {rootfig-0.1.0 → rootfig-0.2.0}/tests/conftest.py +0 -0
  104. {rootfig-0.1.0 → rootfig-0.2.0}/tests/data/split_collection.root +0 -0
  105. {rootfig-0.1.0 → rootfig-0.2.0}/tests/test_expressions.py +0 -0
  106. {rootfig-0.1.0 → rootfig-0.2.0}/tests/test_tutorials.py +0 -0
@@ -9,6 +9,7 @@ build/
9
9
  .ruff_cache/
10
10
  .coverage
11
11
  coverage.xml
12
+ junit.xml
12
13
  htmlcov/
13
14
  site/
14
15
  *.root
@@ -26,8 +26,10 @@ uv build && uvx twine check dist/* # packaging
26
26
  ```
27
27
 
28
28
  CI runs all of these on Python 3.12, 3.13 and 3.14 (Linux), plus macOS
29
- on 3.13. Tests that use ROOT's tutorial files run only when
30
- `root-config` is available locally and are skipped otherwise; do not add
29
+ on 3.13, and once more with every direct dependency at the minimum version
30
+ declared in `pyproject.toml` (`uv sync --resolution lowest-direct`). Tests
31
+ that use ROOT's tutorial files run only when `root-config` is available
32
+ locally and are skipped otherwise; do not add
31
33
  tests that require ROOT or network access.
32
34
 
33
35
  ## Layout
@@ -111,18 +113,17 @@ docstring; the test suite fails until its baseline image exists.
111
113
 
112
114
  - Add tests for behaviour changes; unit tests assert histogram contents and
113
115
  matplotlib structure. Rendered output is covered by the gallery (below).
114
- - Update `CHANGELOG.md` under "Unreleased".
115
116
  - Run the checks above before pushing; `pre-commit run --all-files` does most
116
117
  of it.
117
118
 
118
119
  ## Releasing
119
120
 
120
- 1. Update the version in `src/rootfig/__init__.py` and move the changelog
121
- entries under a new heading.
121
+ 1. Update the version in `src/rootfig/__init__.py`.
122
122
  2. Commit, tag `vX.Y.Z`, push the tag.
123
- 3. The `release.yml` workflow builds the distribution and publishes it to
124
- PyPI via Trusted Publishing (configure the publisher on PyPI first:
125
- repository `jbeirer/rootfig`, workflow `release.yml`, environment `pypi`).
123
+ 3. The `release.yml` workflow builds the distribution, refuses a tag that does
124
+ not match `rootfig.__version__`, and publishes it to PyPI via Trusted
125
+ Publishing (configure the publisher on PyPI first: repository
126
+ `jbeirer/rootfig`, workflow `release.yml`, environment `pypi`).
126
127
 
127
128
  The documentation is published by the `docs` job of `ci.yml` on every push to
128
129
  `main` (`mkdocs gh-deploy` to the `gh-pages` branch). Once, in the repository
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rootfig
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Publication-quality figures straight from ROOT trees, without ROOT: uproot + Awkward + hist + mplhep with a TTree::Draw-like API.
5
5
  Project-URL: Homepage, https://github.com/jbeirer/rootfig
6
6
  Project-URL: Documentation, https://github.com/jbeirer/rootfig#readme
7
7
  Project-URL: Repository, https://github.com/jbeirer/rootfig
8
8
  Project-URL: Issues, https://github.com/jbeirer/rootfig/issues
9
- Project-URL: Changelog, https://github.com/jbeirer/rootfig/blob/main/CHANGELOG.md
10
9
  Author-email: Joshua Falco Beirer <jbeirer@cern.ch>
11
10
  License-Expression: MIT
12
11
  License-File: LICENSE
@@ -24,12 +23,12 @@ Classifier: Topic :: Scientific/Engineering :: Physics
24
23
  Classifier: Topic :: Scientific/Engineering :: Visualization
25
24
  Classifier: Typing :: Typed
26
25
  Requires-Python: >=3.12
27
- Requires-Dist: awkward>=2.6
28
- Requires-Dist: hist>=2.7
29
- Requires-Dist: matplotlib>=3.10
30
- Requires-Dist: mplhep>=0.3.50
26
+ Requires-Dist: awkward>=2.8
27
+ Requires-Dist: hist>=2.7.2
28
+ Requires-Dist: matplotlib>=3.11
29
+ Requires-Dist: mplhep>=1.3
31
30
  Requires-Dist: numpy>=1.26
32
- Requires-Dist: uproot>=5.3
31
+ Requires-Dist: uproot>=5.7.4
33
32
  Description-Content-Type: text/markdown
34
33
 
35
34
  # rootfig
@@ -44,8 +44,12 @@ mem = rf.Sample({"x": awkward_array, "w": weights}, label="in memory")
44
44
  ```
45
45
 
46
46
  Without a luminosity such samples raise a [`LuminosityError`][rootfig.LuminosityError].
47
- - `entry_start`/`entry_stop` restrict reading for quick looks at large files.
48
- - `sample.with_(label="...")` returns a modified copy.
47
+ - `entry_start`/`entry_stop` restrict reading for quick looks at large files
48
+ (a plot reads every needed branch of every file into memory at once). For a
49
+ ready-made `FileSource`, give the range to the source itself; passing it to
50
+ `Sample` afterwards raises a [`SourceError`][rootfig.SourceError].
51
+ - `sample.with_(label="...")` returns a modified copy; replacement values are
52
+ validated like constructor arguments.
49
53
 
50
54
  Passing a list of files to `plot()` creates one sample per file. To merge
51
55
  several files into *one* sample, use a glob or a `Sample`.
@@ -67,7 +71,8 @@ met = rf.Variable(
67
71
  `"robust"` (ignores far outliers such as `-999` sentinels).
68
72
  - `label` and `unit` form the axis label `label [unit]`; the unit also appears
69
73
  in the automatic y label (`Events / 4 GeV`).
70
- - `name` is used for file names by `Plot.save(directory)`.
74
+ - `name` is used for file names by `Plot.save(directory)`; it must be a plain
75
+ file stem (no path separators).
71
76
 
72
77
  `bins`, `range`, `xlabel` and `unit` given to `plot()` override the variable.
73
78
 
@@ -54,6 +54,9 @@ would do to a matplotlib figure, you can do to `Plot.fig` and `Plot.ax`.
54
54
  - You already have flat NumPy columns or `boost-histogram` objects and want
55
55
  rich comparison panels: [plothist](https://plothist.readthedocs.io).
56
56
  - You need a full columnar analysis framework with lazy, distributed
57
- processing: [coffea](https://coffeateam.github.io/coffea/).
57
+ processing: [coffea](https://coffeateam.github.io/coffea/). rootfig reads
58
+ every branch a plot needs from every file into memory at once (only the
59
+ required branches, optionally an entry range), which suits quick looks and
60
+ moderate ntuples, not multi-terabyte datasets.
58
61
  - You want to build fit templates and workspaces: [cabinetry](https://cabinetry.readthedocs.io).
59
62
  - You want a quick terminal look at a branch: [histoprint](https://github.com/scikit-hep/histoprint).
@@ -16,9 +16,11 @@ syntax evaluated with NumPy/Awkward semantics over the branches of the tree.
16
16
  | Constants | `pi`, `e`, `inf`, `nan`, `True`, `False` |
17
17
 
18
18
  `and`, `or`, `not` and chained comparisons are rewritten to element-wise
19
- operations, so they work on arrays. Attribute access, lambdas,
20
- comprehensions, string literals and calls to anything but the functions
21
- below are rejected at parse time with an [`ExpressionError`][rootfig.ExpressionError].
19
+ operations, so they work on arrays. Dotted names such as
20
+ `ReconstructedParticles.momentum.x` are read as one branch name (see below);
21
+ any other attribute access, lambdas, comprehensions, string literals and calls
22
+ to anything but the functions below are rejected at parse time with an
23
+ [`ExpressionError`][rootfig.ExpressionError].
22
24
  An unknown branch raises [`MissingBranchError`][rootfig.MissingBranchError]
23
25
  with close-match suggestions.
24
26
 
@@ -43,19 +43,32 @@ dataset scaled to the full one, for instance).
43
43
  Variances are scaled consistently. Flow bins scale with the same factor; for
44
44
  `"width"` and `"density"` they are divided by the width of the neighbouring
45
45
  visible bin. Plain `hist.Hist` objects with a count storage passed to
46
- `plot_histograms` are converted to `Weight` storage first.
46
+ `plot_histograms` are converted to `Weight` storage first. If such a histogram
47
+ was filled with weights (or rescaled) its sum of squared weights is lost, and
48
+ rootfig refuses it with a `ValueError` rather than invent uncertainties; pass
49
+ `assume_poisson=True` to use the absolute bin contents as variances (with a
50
+ warning), or fill with `hist.storage.Weight()` in the first place.
51
+
52
+ The rescaling modes divide by the signed sum of the visible bins: a histogram
53
+ dominated by negative weights still sums to the target, its shape flips sign,
54
+ and a warning says so. An empty histogram, or one whose positive and negative
55
+ weights cancel exactly, is left unchanged with a warning and keeps the plain
56
+ `Events` label (`Histogram.normalization` stays `None`).
47
57
 
48
58
  ## Ratio panel
49
59
 
50
60
  `ratio=True` adds a lower panel sharing the x axis:
51
61
 
52
62
  - with a stack: data / total MC, error bars from the data, grey band for the
53
- MC statistical uncertainty (`ratio_uncertainty="numerator"`);
54
- - with data and overlaid samples: data / first sample;
55
- - otherwise: every sample / the first sample, uncertainties of both
63
+ MC statistical uncertainty (`ratio_uncertainty="numerator"`); a stacked
64
+ ratio needs an `observed=` sample;
65
+ - with data and overlaid samples: data / the first non-data sample (only the
66
+ data appears in the panel);
67
+ - otherwise: every further sample / the first sample, uncertainties of both
56
68
  propagated in quadrature (`ratio_uncertainty="propagate"`).
57
69
 
58
- `ratio="Background"` picks the reference by label.
70
+ `ratio="Background"` picks the reference by label; all other histograms, data
71
+ included, are divided by it.
59
72
 
60
73
  `ratio="significance"` (or `"s/sqrt(b)"`, `"s/sqrt(s+b)"`) draws a
61
74
  **significance panel** instead: per bin, the signal over the square root of
@@ -113,6 +126,12 @@ computed values are returned in `Plot.ratios` as
113
126
  `(main, ratio)` for ratio plots), so several rootfig plots can share a figure.
114
127
  - `save="file.pdf"` saves immediately; `Plot.save(path)` accepts a directory
115
128
  (file named after the variable) and `formats=["pdf", "png"]`.
129
+ - Figures use matplotlib's constrained layout, so labels, legends and colour
130
+ bars fit inside the canvas and a saved file has exactly the `figsize`
131
+ dimensions: 1D, 2D and ratio plots of one size share one shape. Figures
132
+ drawn into your own `ax` are saved with a tight bounding box instead.
133
+ - Fonts are fixed on the figure when it is made, so saving or displaying it
134
+ later renders exactly the layout that was computed, in the style's fonts.
116
135
  - `Plot.fig`, `Plot.ax`, `Plot.ratio_ax` are plain matplotlib objects;
117
136
  `Plot.histograms` wrap the `hist.Hist` objects with labels and statistics.
118
137
 
@@ -126,6 +145,8 @@ rf.correlation(sample, ["MET", "nJet", "HT"], selection="nJet >= 2", percent=Tru
126
145
  `rf.plot2d` options: `logz`, `logx`, `logy`, `cmap` (any matplotlib colour
127
146
  map, default `viridis`), `colorbar=False`, `zlabel` (default `Events` or the
128
147
  normalisation), `normalize`, `title`, `text`, `style`, `figsize`, `ax`, `save`.
148
+ The figure has the same size as a 1D plot; the colour bar takes its space from
149
+ the axes.
129
150
 
130
151
  `rf.correlation` options: `labels` (tick labels, default the variable labels),
131
152
  `percent=True` (integer percentages instead of two-decimal coefficients),
@@ -168,7 +189,9 @@ Negative weights (NLO samples) can make a weighted variance negative or an
168
189
  efficiency leave `[0, 1]`. rootfig then reports `nan` for the standard
169
190
  deviation, the profile error or the confidence interval (with a warning for
170
191
  efficiencies) rather than a made-up uncertainty; means, yields and histogram
171
- contents are unaffected.
192
+ contents are unaffected. Bins whose total weight is negative keep their mean
193
+ or efficiency (the plain ratio) but get no uncertainty; bins whose weights
194
+ cancel to exactly zero count as empty (`nan`).
172
195
 
173
196
  ## Cut flows
174
197
 
@@ -183,6 +206,10 @@ table.get("ZH").efficiencies # relative to the previous step
183
206
  table.get("ZH").absolute_efficiencies
184
207
  ```
185
208
 
209
+ Step efficiencies are plain ratios of weighted yields (`nan` after a zero
210
+ yield). With signed (NLO) weights a yield can be negative and a ratio can lie
211
+ outside `[0, 1]`; it is reported as is.
212
+
186
213
  Cuts apply cumulatively; a sample's own selection is the first row. Per-object
187
214
  cuts pass an event when any object passes. `weight`, `lumi` and `nonfinite`
188
215
  work as in `plot()`: events with a missing or non-finite weight are excluded
@@ -26,13 +26,16 @@ classifiers = [
26
26
  "Topic :: Scientific/Engineering :: Visualization",
27
27
  "Typing :: Typed",
28
28
  ]
29
+ # Floors are the oldest versions the test suite passes with (CI job "minimum-versions"):
30
+ # uproot 5.7.4 reads nested RNTuple fields and entry ranges across files and writes the
31
+ # fixed-size test arrays; mplhep 1.3 has exp_label(text=) and hist2dplot(mask=).
29
32
  dependencies = [
30
- "uproot>=5.3",
31
- "awkward>=2.6",
33
+ "uproot>=5.7.4",
34
+ "awkward>=2.8",
32
35
  "numpy>=1.26",
33
- "hist>=2.7",
34
- "mplhep>=0.3.50",
35
- "matplotlib>=3.10",
36
+ "hist>=2.7.2",
37
+ "mplhep>=1.3",
38
+ "matplotlib>=3.11",
36
39
  ]
37
40
 
38
41
  [project.urls]
@@ -40,7 +43,6 @@ Homepage = "https://github.com/jbeirer/rootfig"
40
43
  Documentation = "https://github.com/jbeirer/rootfig#readme"
41
44
  Repository = "https://github.com/jbeirer/rootfig"
42
45
  Issues = "https://github.com/jbeirer/rootfig/issues"
43
- Changelog = "https://github.com/jbeirer/rootfig/blob/main/CHANGELOG.md"
44
46
 
45
47
  [dependency-groups]
46
48
  dev = [
@@ -62,7 +64,7 @@ path = "src/rootfig/__init__.py"
62
64
  [tool.hatch.build.targets.sdist]
63
65
  include = [
64
66
  "src/rootfig", "tests", "docs", "examples", "mkdocs.yml",
65
- "README.md", "CHANGELOG.md", "CONTRIBUTING.md", "LICENSE", "pyproject.toml",
67
+ "README.md", "CONTRIBUTING.md", "LICENSE", "pyproject.toml",
66
68
  ]
67
69
 
68
70
  [tool.hatch.build.targets.wheel]
@@ -52,7 +52,7 @@ from rootfig.histograms import (
52
52
  from rootfig.model import Cut, Sample, Style, Variable, log_bins
53
53
  from rootfig.plotting import Plot, use_style
54
54
 
55
- __version__ = "0.1.0"
55
+ __version__ = "0.2.0"
56
56
 
57
57
  __all__ = [
58
58
  "BinningError",
@@ -14,11 +14,10 @@ from dataclasses import dataclass
14
14
  from typing import Any
15
15
 
16
16
  import awkward as ak
17
- import matplotlib.pyplot as plt
18
17
  import numpy as np
19
18
 
20
19
  from rootfig._typing import FloatArray, Hist
21
- from rootfig.errors import RootfigWarning, SelectionError, SourceError
20
+ from rootfig.errors import BinningError, RootfigWarning, SelectionError, SourceError
22
21
  from rootfig.expressions import parse
23
22
  from rootfig.histograms import (
24
23
  SIGNIFICANCE_KINDS,
@@ -36,6 +35,7 @@ from rootfig.histograms import (
36
35
  correlation_matrix,
37
36
  describe_table,
38
37
  load_columns,
38
+ load_columns_each,
39
39
  read_arrays,
40
40
  significance,
41
41
  )
@@ -76,6 +76,7 @@ from rootfig.plotting import (
76
76
  draw_ratio_panel,
77
77
  draw_significance_panel,
78
78
  envelope,
79
+ finalize_figure,
79
80
  finish_axes,
80
81
  fold_flow_bins,
81
82
  label_flow_bins,
@@ -345,9 +346,11 @@ def plot(
345
346
  stack
346
347
  Stack the non-data samples.
347
348
  ratio
348
- ``True`` for a ratio panel (data / total for stacks or data plots,
349
- otherwise each sample over the first), a sample label to use as the
350
- reference, or a significance panel: ``"significance"`` (``S/sqrt(B)``),
349
+ ``True`` for a ratio panel: data / total MC for a stack (needs
350
+ ``observed=``), data / the first non-data sample when data is overlaid,
351
+ otherwise every further sample over the first; a sample label to use
352
+ as the reference (all other histograms, data included, are divided by
353
+ it); or a significance panel: ``"significance"`` (``S/sqrt(B)``),
351
354
  ``"s/sqrt(b)"`` or ``"s/sqrt(s+b)"``, where the signal is the last
352
355
  non-data sample (the top of a stack) and the background the sum of the
353
356
  others; ``("s/sqrt(b)", "Signal")`` names the signal sample.
@@ -462,6 +465,7 @@ def plot_histograms(
462
465
  style: StyleLike = None,
463
466
  figsize: tuple[float, float] | None = None,
464
467
  ax: AxesLike = None,
468
+ assume_poisson: bool = False,
465
469
  save: str | None = None,
466
470
  ) -> Plot:
467
471
  """Draw already-filled histograms (``hist.Hist`` or :class:`~rootfig.histograms.Histogram`).
@@ -472,10 +476,16 @@ def plot_histograms(
472
476
  :class:`~rootfig.histograms.Histogram` objects with ``is_data=True``.
473
477
  Overlaid histograms may have different binnings; stacks, ratio panels and
474
478
  ``flow="show"`` need identical bin edges.
479
+
480
+ A count-storage histogram that was filled with weights (or rescaled) has
481
+ lost its sum of squared weights and is rejected with a ``ValueError``;
482
+ ``assume_poisson=True`` draws it anyway with the absolute bin contents as
483
+ variances (a warning is issued). Fill with ``hist.storage.Weight()`` to keep
484
+ the real uncertainties.
475
485
  """
476
486
  if logx is None:
477
487
  logx = variable.log if variable is not None else False
478
- histograms_ = _wrap_hists(hists, labels)
488
+ histograms_ = _wrap_hists(hists, labels, assume_poisson=assume_poisson)
479
489
  if not histograms_:
480
490
  msg = "no histograms to draw"
481
491
  raise ValueError(msg)
@@ -644,6 +654,7 @@ def plot_histograms(
644
654
  if layout.ratio is not None and layout.ratio_right is not None:
645
655
  apply_xbreak(layout.ratio, layout.ratio_right, *segments)
646
656
 
657
+ finalize_figure(layout.fig, layout.main) # last: fonts and label anchoring
647
658
  result = Plot(
648
659
  fig=layout.fig,
649
660
  ax=layout.main,
@@ -693,10 +704,11 @@ def plot2d(
693
704
 
694
705
  ``x`` and ``y`` must have the same structure (both per-event, or both
695
706
  per-object from the same collection). ``bins`` applies to both axes unless
696
- it is a pair of binning specifications, one per axis; per-axis ranges,
697
- labels and logarithmic scales (``logx``/``logy`` default to the variables'
698
- ``log`` flags) are best given through :class:`~rootfig.model.Variable`
699
- objects.
707
+ it is a pair of binning specifications, one per axis (so ``(40, 20)`` is
708
+ two bin counts, never a range; a range needs ``(n, low, high)``); per-axis
709
+ ranges, labels and logarithmic scales (``logx``/``logy`` default to the
710
+ variables' ``log`` flags) are best given through
711
+ :class:`~rootfig.model.Variable` objects.
700
712
  """
701
713
  sample = _single_sample(data, tree=tree)
702
714
  x_bins, y_bins = _split_bins(bins)
@@ -711,11 +723,9 @@ def plot2d(
711
723
  histogram_ = _normalize_for_plot(histogram_, normalize)
712
724
  resolved_style = _style_for(style, text, lumi)
713
725
  with style_context(resolved_style) as st:
714
- default_size = st.figsize or figsize
715
- if default_size is None:
716
- width, height = plt.rcParams["figure.figsize"]
717
- default_size = (width * 1.15, height)
718
- layout = make_figure(st, ratio=False, ax=ax, figsize=default_size)
726
+ # Same canvas as a 1D plot; the colour bar takes its space from the main axes
727
+ # (draw_hist2d stops mplhep from widening the figure).
728
+ layout = make_figure(st, ratio=False, ax=ax, figsize=figsize or st.figsize)
719
729
  fig, main_ax = layout.fig, layout.main
720
730
  draw_hist2d(
721
731
  histogram_,
@@ -732,6 +742,7 @@ def plot2d(
732
742
  if title:
733
743
  main_ax.set_title(title)
734
744
  add_experiment_label(main_ax, st, has_data=sample.is_data)
745
+ finalize_figure(fig, main_ax) # last: fonts and label anchoring
735
746
  result = Plot(fig=fig, ax=main_ax, histograms=[histogram_], variable=var_x)
736
747
  if save:
737
748
  result.save(save)
@@ -812,10 +823,11 @@ def summarize(
812
823
  var_list = [variables] if isinstance(variables, str | Variable) else list(variables)
813
824
  rows: list[tuple[str, str, Summary]] = []
814
825
  for sample in samples:
815
- for var in var_list:
816
- columns = load_columns(
817
- sample, [var], selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
818
- )
826
+ # One read per sample: the branches of all variables are fetched together.
827
+ per_variable = load_columns_each(
828
+ sample, var_list, selection=selection, weight=weight, lumi=lumi, nonfinite=nonfinite
829
+ )
830
+ for var, columns in zip(var_list, per_variable, strict=True):
819
831
  rows.append((sample.label, as_variable(var).expression, summarize_columns(columns)))
820
832
  return SummaryTable(tuple(rows))
821
833
 
@@ -868,6 +880,7 @@ def correlation(
868
880
  matrix, tick_labels, main_ax, cmap=cmap, annotate=annotate, percent=percent
869
881
  )
870
882
  main_ax.set_title(title if title is not None else f"{sample.label}: correlation")
883
+ finalize_figure(fig, main_ax)
871
884
  result = Plot(fig=fig, ax=main_ax, matrix=matrix)
872
885
  if save:
873
886
  result.save(save)
@@ -1013,6 +1026,7 @@ def efficiency(
1013
1026
  logy=False,
1014
1027
  floating=[legend_artist] if floating and legend_artist is not None else [],
1015
1028
  )
1029
+ finalize_figure(layout.fig, layout.main) # last: fonts and label anchoring
1016
1030
  result = Plot(
1017
1031
  fig=layout.fig,
1018
1032
  ax=layout.main,
@@ -1062,7 +1076,8 @@ def profile(
1062
1076
  e.g. ``"(reco_pt - true_pt) / true_pt"``). ``x`` and ``y`` must have the same
1063
1077
  structure (both per-event or both per-object of one collection). ``xlabel``
1064
1078
  and ``unit`` describe the x axis; ``logx``/``logy`` default to the
1065
- variables' ``log`` flags. With negative weights a bin whose weighted
1079
+ variables' ``log`` flags. With negative weights a bin whose total weight
1080
+ is negative keeps its mean but has no error, and a bin whose weighted
1066
1081
  variance is negative has no standard deviation (``nan``). The
1067
1082
  :class:`~rootfig.histograms.Profile` objects are returned in ``Plot.profiles``.
1068
1083
 
@@ -1142,6 +1157,7 @@ def profile(
1142
1157
  logy=logy,
1143
1158
  floating=[legend_artist] if floating and legend_artist is not None else [],
1144
1159
  )
1160
+ finalize_figure(layout.fig, layout.main) # last: fonts and label anchoring
1145
1161
  result = Plot(fig=layout.fig, ax=layout.main, variable=var_x, profiles=profiles)
1146
1162
  if save:
1147
1163
  result.save(save)
@@ -1221,7 +1237,12 @@ RatioSpec = bool | str | tuple[str, str]
1221
1237
  """What ``ratio=`` accepts: a flag, a reference label, a significance kind, or (kind, signal)."""
1222
1238
 
1223
1239
 
1224
- def _wrap_hists(hists: Sequence[Histogram | Hist], labels: Sequence[str] | None) -> list[Histogram]:
1240
+ def _wrap_hists(
1241
+ hists: Sequence[Histogram | Hist],
1242
+ labels: Sequence[str] | None,
1243
+ *,
1244
+ assume_poisson: bool = False,
1245
+ ) -> list[Histogram]:
1225
1246
  if labels is not None and len(labels) != len(hists):
1226
1247
  msg = f"got {len(labels)} labels for {len(hists)} histograms"
1227
1248
  raise ValueError(msg)
@@ -1235,7 +1256,8 @@ def _wrap_hists(hists: Sequence[Histogram | Hist], labels: Sequence[str] | None)
1235
1256
  else:
1236
1257
  axis_name = item.axes[0].name if item.ndim == 1 else ""
1237
1258
  label = axis_name or f"hist {index + 1}"
1238
- wrapped.append(Histogram(as_weight_storage(item), label=str(label)))
1259
+ converted = as_weight_storage(item, assume_poisson=assume_poisson)
1260
+ wrapped.append(Histogram(converted, label=str(label)))
1239
1261
  return wrapped
1240
1262
 
1241
1263
 
@@ -1289,9 +1311,27 @@ def _ratio_setup(
1289
1311
 
1290
1312
 
1291
1313
  def _split_bins(bins: Bins | tuple[Bins, Bins] | None) -> tuple[Any, Any]:
1292
- """Interpret ``bins`` for two axes: a pair of specifications, or one spec for both."""
1314
+ """Interpret ``bins`` for two axes: a pair of specifications, or one spec for both.
1315
+
1316
+ Raises
1317
+ ------
1318
+ BinningError
1319
+ If the pair consists of two numbers that are not both integers: that
1320
+ reads like a ``(low, high)`` range, which a pair never is.
1321
+ """
1293
1322
  if bins is None:
1294
1323
  return None, None
1295
1324
  if isinstance(bins, tuple) and len(bins) == 2:
1325
+ numbers = all(
1326
+ isinstance(b, int | float | np.number) and not isinstance(b, bool) for b in bins
1327
+ )
1328
+ if numbers and not all(isinstance(b, int | np.integer) for b in bins):
1329
+ msg = (
1330
+ f"bins={bins!r}: for plot2d a 2-tuple is (x_bins, y_bins), one specification "
1331
+ "per axis, so two numbers are two bin counts, not a range. Give the range per "
1332
+ f"axis, e.g. bins=((50, {bins[0]}, {bins[1]}), (50, {bins[0]}, {bins[1]})), or "
1333
+ "use rf.Variable(x, bins=50, range=(low, high))"
1334
+ )
1335
+ raise BinningError(msg)
1296
1336
  return bins[0], bins[1]
1297
1337
  return bins, bins
@@ -349,7 +349,10 @@ def parse(expression: ExpressionLike) -> Expression:
349
349
  ------
350
350
  ExpressionError
351
351
  If the text is not a single Python expression or uses a disallowed
352
- construct (attribute access, unknown functions, string literals, ...).
352
+ construct (unknown functions, string literals, lambdas, comprehensions,
353
+ conditional expressions, ...). Dotted names such as
354
+ ``Collection.field.sub`` are read as one branch name (podio/EDM4hep
355
+ files), not as attribute access; attributes of anything else are rejected.
353
356
  """
354
357
  if isinstance(expression, Expression):
355
358
  return expression
@@ -15,6 +15,7 @@ from rootfig.histograms.pipeline import (
15
15
  combined_selection,
16
16
  combined_weight,
17
17
  load_columns,
18
+ load_columns_each,
18
19
  read_arrays,
19
20
  source_length,
20
21
  )
@@ -55,6 +56,7 @@ __all__ = [
55
56
  "efficiency",
56
57
  "fill",
57
58
  "load_columns",
59
+ "load_columns_each",
58
60
  "normalization_label",
59
61
  "normalize",
60
62
  "normalize_hist",