reboost 0.8.3__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.
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ import awkward as ak
6
+ import numpy as np
7
+ from lgdo import Array, VectorOfVectors
8
+ from lgdo.types import LGDO
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ def piecewise_linear_activeness(
14
+ distances: VectorOfVectors | ak.Array, fccd: float, dlf: float
15
+ ) -> VectorOfVectors | Array:
16
+ r"""Piecewise linear HPGe activeness model.
17
+
18
+ Based on:
19
+
20
+ .. math::
21
+
22
+ f(d) =
23
+ \begin{cases}
24
+ 0 & \text{if } d < f*l, \\
25
+ \frac{x-f*l}{f - f*l} & \text{if } t \leq d < f, \\
26
+ 1 & \text{otherwise.}
27
+ \end{cases}
28
+
29
+ Where:
30
+
31
+ - `d`: Distance to surface,
32
+ - `l`: Dead layer fraction, the fraction of the FCCD which is fully inactive
33
+ - `f`: Full charge collection depth (FCCD).
34
+
35
+ In addition, any distance of `np.nan` (for example if the calculation
36
+ was not performed for some steps) is assigned an activeness of one.
37
+
38
+ Parameters
39
+ ----------
40
+ distances
41
+ the distance from each step to the detector surface. Can be either a
42
+ `numpy` or `awkward` array, or a LGDO `VectorOfVectors` or `Array`. The computation
43
+ is performed for each element and the shape preserved in the output.
44
+
45
+ fccd
46
+ the value of the FCCD
47
+ dlf
48
+ the fraction of the FCCD which is fully inactive.
49
+
50
+ Returns
51
+ -------
52
+ a :class:`VectorOfVectors` or :class:`Array` of the activeness
53
+ """
54
+ # convert to ak
55
+ if isinstance(distances, LGDO):
56
+ distances_ak = distances.view_as("ak")
57
+ elif not isinstance(distances, ak.Array):
58
+ distances_ak = ak.Array(distances)
59
+ else:
60
+ distances_ak = distances
61
+
62
+ dl = fccd * dlf
63
+ distances_flat = (
64
+ ak.flatten(distances_ak).to_numpy() if distances_ak.ndim > 1 else distances_ak.to_numpy()
65
+ )
66
+
67
+ # compute the linear piecewise
68
+ results = np.full_like(distances_flat, np.nan, dtype=np.float64)
69
+ lengths = ak.num(distances_ak) if distances_ak.ndim > 1 else len(distances_ak)
70
+
71
+ mask1 = (distances_flat > fccd) | np.isnan(distances_flat)
72
+ mask2 = (distances_flat <= dl) & (~mask1)
73
+ mask3 = ~(mask1 | mask2)
74
+
75
+ # assign the values
76
+ results[mask1] = 1
77
+ results[mask2] = 0
78
+ results[mask3] = (distances_flat[mask3] - dl) / (fccd - dl)
79
+
80
+ # reshape
81
+ results = ak.unflatten(ak.Array(results), lengths) if distances_ak.ndim > 1 else results
82
+
83
+ return VectorOfVectors(results) if results.ndim > 1 else Array(results)
84
+
85
+
86
+ def vectorised_active_energy(
87
+ distances: VectorOfVectors | ak.Array,
88
+ edep: VectorOfVectors | ak.Array,
89
+ fccd: float | list,
90
+ dlf: float | list,
91
+ ) -> VectorOfVectors | Array:
92
+ r"""Energy after piecewise linear HPGe activeness model vectorised over FCCD or dead layer fraction.
93
+
94
+ Based on the same linear activeness function as :func:`piecewise_linear_activeness`. However,
95
+ this function vectorises the calculation to provide a range of output energies varying the fccd or
96
+ dead layer fraction. Either fccd or dlf can be a list. This adds an extra dimension to the
97
+ output, with the same length as the input fccd or dlf list.
98
+
99
+ .. warning:
100
+ It is not currently implemented to vary both dlf and fccd.
101
+
102
+ Parameters
103
+ ----------
104
+ distances
105
+ the distance from each step to the detector surface. Can be either a
106
+ `awkward` array, or a LGDO `VectorOfVectors` . The computation
107
+ is performed for each element and the first dimension is preserved, a
108
+ new dimension is added vectorising over the FCCD or DLF.
109
+ edep
110
+ the energy for each step.
111
+ fccd
112
+ the value of the FCCD, can be a list.
113
+ dlf
114
+ the fraction of the FCCD which is fully inactive, can be a list.
115
+
116
+ Returns
117
+ -------
118
+ a :class:`VectorOfVectors` or :class:`Array` of the activeness
119
+ """
120
+ # add checks on fccd, dlf
121
+ fccd = np.array(fccd)
122
+ dlf = np.array(dlf)
123
+
124
+ if (fccd.ndim + dlf.ndim) > 1:
125
+ msg = "Currently only one of FCCD and dlf can be varied"
126
+ raise NotImplementedError(msg)
127
+
128
+ # convert fccd and or dlf to the right shape
129
+ if fccd.ndim == 0:
130
+ if dlf.ndim == 0:
131
+ dlf = dlf[np.newaxis]
132
+ fccd = np.full_like(dlf, fccd)
133
+
134
+ dl = fccd * dlf
135
+
136
+ def _convert(field):
137
+ # convert to ak
138
+ if isinstance(field, VectorOfVectors):
139
+ field_ak = field.view_as("ak")
140
+ elif not isinstance(field, ak.Array):
141
+ field_ak = ak.Array(field)
142
+ else:
143
+ msg = f"{field} must be an awkward array or VectorOfVectors"
144
+ raise TypeError(msg)
145
+
146
+ return field_ak, ak.flatten(field_ak).to_numpy()[:, np.newaxis]
147
+
148
+ distances_ak, distances_flat = _convert(distances)
149
+ _, edep_flat = _convert(edep)
150
+ runs = ak.num(distances_ak, axis=-1)
151
+
152
+ # vectorise fccd or tl
153
+
154
+ fccd_list = np.tile(fccd, (len(distances_flat), 1))
155
+ dl_list = np.tile(dl, (len(distances_flat), 1))
156
+ distances_shaped = np.tile(distances_flat, (1, len(dl)))
157
+
158
+ # compute the linear piecewise
159
+ results = np.full_like(fccd_list, np.nan, dtype=np.float64)
160
+
161
+ # Masks
162
+ mask1 = (distances_shaped > fccd_list) | np.isnan(distances_shaped)
163
+ mask2 = ((distances_shaped <= dl_list) | (fccd_list == dl_list)) & ~mask1
164
+ mask3 = ~(mask1 | mask2) # Safe, avoids recomputing anything expensive
165
+
166
+ # Assign values
167
+ results[mask1] = 1.0
168
+ results[mask2] = 0.0
169
+ results[mask3] = (distances_shaped[mask3] - dl_list[mask3]) / (
170
+ fccd_list[mask3] - dl_list[mask3]
171
+ )
172
+
173
+ energy = ak.sum(ak.unflatten(results * edep_flat, runs), axis=-2)
174
+
175
+ return VectorOfVectors(energy) if energy.ndim > 1 else Array(energy.to_numpy())
reboost/math/stats.py ADDED
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+
6
+ import awkward as ak
7
+ import numpy as np
8
+ from lgdo import Array
9
+ from numpy.typing import ArrayLike
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ def get_resolution(
15
+ energies: ak.Array, channels: ak.Array, tcm_tables: dict, reso_pars: dict, reso_func: Callable
16
+ ) -> ak.Array:
17
+ """Get the resolution for each energy.
18
+
19
+ Parameters
20
+ ----------
21
+ energies
22
+ the energies to smear
23
+ channels
24
+ the channel index for each energy
25
+ tcm_tables
26
+ the mapping from indices to channel names.
27
+ reso_pars
28
+ the pars for each channel.
29
+ reso_func
30
+ the function to compute the resolution.
31
+ """
32
+ n_pars = len(reso_pars[next(iter(reso_pars))])
33
+
34
+ pars_shaped = []
35
+
36
+ for _ in range(n_pars):
37
+ pars_shaped.append(np.zeros(len(ak.flatten(channels))))
38
+
39
+ num = ak.num(channels, axis=-1)
40
+
41
+ for key, value in tcm_tables.items():
42
+ for i in range(n_pars):
43
+ pars_shaped[i][ak.flatten(channels) == value] = reso_pars[key][i]
44
+
45
+ ch_reso = reso_func(ak.flatten(energies), *pars_shaped)
46
+ return ak.unflatten(ch_reso, num)
47
+
48
+
49
+ def apply_energy_resolution(
50
+ energies: ak.Array, channels: ak.Array, tcm_tables: dict, reso_pars: dict, reso_func: Callable
51
+ ):
52
+ """Apply the energy resolution sampling to an array with many channels.
53
+
54
+ Parameters
55
+ ----------
56
+ energies
57
+ the energies to smear
58
+ channels
59
+ the channel index for each energy
60
+ tcm_tables
61
+ the mapping from indices to channel names.
62
+ reso_pars
63
+ the pars for each channel.
64
+ reso_func
65
+ the function to compute the resolution.
66
+ """
67
+ num = ak.num(channels, axis=-1)
68
+
69
+ ch_reso = get_resolution(energies, channels, tcm_tables, reso_pars, reso_func)
70
+ energies_flat_smear = gaussian_sample(ak.flatten(energies), ak.flatten(ch_reso))
71
+
72
+ return ak.unflatten(energies_flat_smear, num)
73
+
74
+
75
+ def gaussian_sample(mu: ArrayLike, sigma: ArrayLike | float, *, seed: int | None = None) -> Array:
76
+ r"""Generate samples from a gaussian.
77
+
78
+ Based on:
79
+
80
+ .. math::
81
+
82
+ y_i \sim \mathcal{N}(\mu_i,\sigma_i)
83
+
84
+ where $y_i$ is the output, $x_i$ the input (mu) and $\sigma$ is the standard
85
+ deviation for each point.
86
+
87
+ Parameters
88
+ ----------
89
+ mu
90
+ the mean positions to sample from, should be a flat (ArrayLike) object.
91
+ sigma
92
+ the standard deviation for each input value, can also be a single float.
93
+ seed
94
+ the random seed.
95
+
96
+ Returns
97
+ -------
98
+ sampled values.
99
+ """
100
+ # convert inputs
101
+
102
+ if isinstance(mu, Array):
103
+ mu = mu.view_as("np")
104
+ elif isinstance(mu, ak.Array):
105
+ mu = mu.to_numpy()
106
+ elif not isinstance(mu, np.ndarray):
107
+ mu = np.array(mu)
108
+
109
+ # similar for sigma
110
+ if isinstance(sigma, Array):
111
+ sigma = sigma.view_as("np")
112
+ elif isinstance(sigma, ak.Array):
113
+ sigma = sigma.to_numpy()
114
+ elif not isinstance(sigma, float | int | np.ndarray):
115
+ sigma = np.array(sigma)
116
+
117
+ rng = np.random.default_rng(seed=seed) # Create a random number generator
118
+
119
+ return Array(rng.normal(loc=mu, scale=sigma))
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .optmap import OpticalMap
4
+
5
+ __all__ = ["OpticalMap"]
reboost/optmap/cli.py ADDED
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+
6
+ import dbetto
7
+
8
+ from ..log_utils import setup_log
9
+ from ..utils import _check_input_file, _check_output_file
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ def optical_cli() -> None:
15
+ parser = argparse.ArgumentParser(
16
+ prog="reboost-optical",
17
+ description="%(prog)s command line interface",
18
+ )
19
+
20
+ parser.add_argument(
21
+ "--verbose",
22
+ "-v",
23
+ action="count",
24
+ default=0,
25
+ help="""Increase the program verbosity""",
26
+ )
27
+
28
+ parser.add_argument(
29
+ "--bufsize",
30
+ action="store",
31
+ type=int,
32
+ default=int(5e6),
33
+ help="""Row count for input table buffering (only used if applicable). default: %(default)e""",
34
+ )
35
+
36
+ subparsers = parser.add_subparsers(dest="command", required=True)
37
+
38
+ # STEP 1a: build map file from evt tier
39
+ map_parser = subparsers.add_parser("createmap", help="build optical map from evt file(s)")
40
+ map_parser.add_argument(
41
+ "--settings",
42
+ action="store",
43
+ help="""Select a config file for binning.""",
44
+ required=True,
45
+ )
46
+ map_parser.add_argument(
47
+ "--detectors",
48
+ help=(
49
+ "file that contains a list of detector ids that will be produced as additional output maps."
50
+ + "By default, all channels will be included."
51
+ ),
52
+ )
53
+ map_parser_det_group = map_parser.add_mutually_exclusive_group(required=True)
54
+ map_parser_det_group.add_argument(
55
+ "--geom",
56
+ help="GDML geometry file",
57
+ )
58
+ map_parser.add_argument(
59
+ "--n-procs",
60
+ "-N",
61
+ type=int,
62
+ default=1,
63
+ help="number of worker processes to use. default: %(default)e",
64
+ )
65
+ map_parser.add_argument(
66
+ "--check",
67
+ action="store_true",
68
+ help="""Check map statistics after creation. default: %(default)s""",
69
+ )
70
+ map_parser.add_argument(
71
+ "input", help="input stp or optmap-evt LH5 file", metavar="INPUT_EVT", nargs="+"
72
+ )
73
+ map_parser.add_argument("output", help="output map LH5 file", metavar="OUTPUT_MAP")
74
+
75
+ # STEP 1b: view maps
76
+ mapview_parser = subparsers.add_parser(
77
+ "viewmap",
78
+ help="view optical map (arrows: navigate slices/axes, 'c': channel selector)",
79
+ formatter_class=argparse.RawTextHelpFormatter,
80
+ description=(
81
+ "Interactively view optical maps stored in LH5 files.\n\n"
82
+ "Keyboard controls:\n"
83
+ " left/right - previous/next slice along the current axis\n"
84
+ " up/down - switch slicing axis (x, y, z)\n"
85
+ " c - open channel selector overlay to switch detector map\n\n"
86
+ "Display notes:\n"
87
+ " - Cells where no primary photons were simulated are shown in white.\n"
88
+ " - Cells where no photons were detected are shown in grey.\n"
89
+ " - Cells with values above the colormap maximum are shown in red.\n"
90
+ " - Use --hist to choose which histogram to display. 'prob_unc_rel' shows the\n"
91
+ " relative uncertainty prob_unc / prob where defined.\n"
92
+ " - Use --divide to show the ratio of two map files (this/other)."
93
+ ),
94
+ epilog=(
95
+ "Examples:\n"
96
+ " reboost-optical viewmap mymap.lh5\n"
97
+ " reboost-optical viewmap mymap.lh5 --channel _1067205\n"
98
+ " reboost-optical viewmap mymap.lh5 --hist prob_unc_rel --min 0 --max 1\n"
99
+ " reboost-optical viewmap mymap.lh5 --divide other.lh5 --title 'Comparison'"
100
+ ),
101
+ )
102
+ mapview_parser.add_argument("input", help="input map LH5 file", metavar="INPUT_MAP")
103
+ mapview_parser.add_argument(
104
+ "--channel",
105
+ action="store",
106
+ default="all",
107
+ help="channel to display ('all' or '_<detid>'). Press 'c' in the viewer to switch. default: %(default)s",
108
+ )
109
+ mapview_parser.add_argument(
110
+ "--hist",
111
+ choices=("_nr_gen", "_nr_det", "prob", "prob_unc", "prob_unc_rel"),
112
+ action="store",
113
+ default="prob",
114
+ help="select optical map histogram to show. default: %(default)s",
115
+ )
116
+ mapview_parser.add_argument(
117
+ "--divide",
118
+ action="store",
119
+ help="divide by another map file before display (ratio). default: none",
120
+ )
121
+ mapview_parser.add_argument(
122
+ "--min",
123
+ default=1e-4,
124
+ type=(lambda s: s if s == "auto" else float(s)),
125
+ help="colormap min value; use 'auto' for automatic scaling. default: %(default)e",
126
+ )
127
+ mapview_parser.add_argument(
128
+ "--max",
129
+ default=1e-2,
130
+ type=(lambda s: s if s == "auto" else float(s)),
131
+ help="colormap max value; use 'auto' for automatic scaling. default: %(default)e",
132
+ )
133
+ mapview_parser.add_argument("--title", help="title of figure. default: stem of filename")
134
+
135
+ # STEP 1c: merge maps
136
+ mapmerge_parser = subparsers.add_parser("mergemap", help="merge optical maps")
137
+ mapmerge_parser.add_argument(
138
+ "input", help="input map LH5 files", metavar="INPUT_MAP", nargs="+"
139
+ )
140
+ mapmerge_parser.add_argument("output", help="output map LH5 file", metavar="OUTPUT_MAP")
141
+ mapmerge_parser.add_argument(
142
+ "--settings",
143
+ action="store",
144
+ help="""Select a config file for binning.""",
145
+ required=True,
146
+ )
147
+ mapmerge_parser.add_argument(
148
+ "--n-procs",
149
+ "-N",
150
+ type=int,
151
+ default=1,
152
+ help="number of worker processes to use. default: %(default)e",
153
+ )
154
+ mapmerge_parser.add_argument(
155
+ "--check",
156
+ action="store_true",
157
+ help="""Check map statistics after creation. default: %(default)s""",
158
+ )
159
+
160
+ # STEP 1d: check map
161
+ checkmap_parser = subparsers.add_parser("checkmap", help="check optical maps")
162
+ checkmap_parser.add_argument("input", help="input map LH5 file", metavar="INPUT_MAP")
163
+
164
+ # STEP X: rebin maps
165
+ rebin_parser = subparsers.add_parser("rebin", help="rebin optical maps")
166
+ rebin_parser.add_argument("input", help="input map LH5 files", metavar="INPUT_MAP")
167
+ rebin_parser.add_argument("output", help="output map LH5 file", metavar="OUTPUT_MAP")
168
+ rebin_parser.add_argument("--factor", type=int, help="integer scale-down factor")
169
+
170
+ args = parser.parse_args()
171
+
172
+ log_level = (None, logging.INFO, logging.DEBUG)[min(args.verbose, 2)]
173
+ setup_log(log_level)
174
+
175
+ # STEP 1a: build map file from evt tier
176
+ if args.command == "createmap":
177
+ from .create import create_optical_maps
178
+
179
+ _check_input_file(parser, args.input)
180
+ _check_output_file(parser, args.output)
181
+
182
+ # load settings for binning from config file.
183
+ _check_input_file(parser, args.input, "settings")
184
+ settings = dbetto.utils.load_dict(args.settings)
185
+
186
+ chfilter = "*"
187
+ if args.detectors is not None:
188
+ # load detector ids from a JSON/YAML array
189
+ chfilter = dbetto.utils.load_dict(args.detectors)
190
+
191
+ create_optical_maps(
192
+ args.input,
193
+ settings,
194
+ args.bufsize,
195
+ chfilter=chfilter,
196
+ output_lh5_fn=args.output,
197
+ check_after_create=args.check,
198
+ n_procs=args.n_procs,
199
+ geom_fn=args.geom,
200
+ )
201
+
202
+ # STEP 1b: view maps
203
+ if args.command == "viewmap":
204
+ from .mapview import view_optmap
205
+
206
+ _check_input_file(parser, args.input)
207
+ if args.divide is not None:
208
+ _check_input_file(parser, args.divide)
209
+ view_optmap(
210
+ args.input,
211
+ args.channel,
212
+ args.divide,
213
+ cmap_min=args.min,
214
+ cmap_max=args.max,
215
+ title=args.title,
216
+ histogram_choice=args.hist,
217
+ )
218
+
219
+ # STEP 1c: merge maps
220
+ if args.command == "mergemap":
221
+ from .create import merge_optical_maps
222
+
223
+ # load settings for binning from config file.
224
+ _check_input_file(parser, args.input, "settings")
225
+ settings = dbetto.utils.load_dict(args.settings)
226
+
227
+ _check_input_file(parser, args.input)
228
+ _check_output_file(parser, args.output)
229
+ merge_optical_maps(
230
+ args.input, args.output, settings, check_after_create=args.check, n_procs=args.n_procs
231
+ )
232
+
233
+ # STEP 1d: check maps
234
+ if args.command == "checkmap":
235
+ from .create import check_optical_map
236
+
237
+ _check_input_file(parser, args.input)
238
+ check_optical_map(args.input)
239
+
240
+ # STEP X: rebin maps
241
+ if args.command == "rebin":
242
+ from .create import rebin_optical_maps
243
+
244
+ _check_input_file(parser, args.input)
245
+ _check_output_file(parser, args.output)
246
+ rebin_optical_maps(args.input, args.output, args.factor)