reboost 0.6.2__py3-none-any.whl → 0.7.0__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.
reboost/hpge/utils.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Callable, NamedTuple
3
+ from collections.abc import Callable
4
+ from typing import NamedTuple
4
5
 
5
6
  import lgdo
6
7
  import numpy as np
reboost/iterator.py CHANGED
@@ -107,7 +107,10 @@ class GLMIterator:
107
107
  glm_n_rows = self.sto.read_n_rows(f"glm/{self.lh5_group}", self.glm_file)
108
108
 
109
109
  # get the number of stp rows
110
- stp_n_rows = self.sto.read_n_rows(f"{self.stp_field}/{self.lh5_group}", self.stp_file)
110
+ try:
111
+ stp_n_rows = self.sto.read_n_rows(f"{self.stp_field}/{self.lh5_group}", self.stp_file)
112
+ except Exception:
113
+ stp_n_rows = 0
111
114
 
112
115
  # heuristics for a good buffer length
113
116
  if self.use_glm:
reboost/math/stats.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- from typing import Callable
4
+ from collections.abc import Callable
5
5
 
6
6
  import awkward as ak
7
7
  import numpy as np
@@ -111,7 +111,7 @@ def gaussian_sample(mu: ArrayLike, sigma: ArrayLike | float, *, seed: int | None
111
111
  sigma = sigma.view_as("np")
112
112
  elif isinstance(sigma, ak.Array):
113
113
  sigma = sigma.to_numpy()
114
- elif not isinstance(sigma, (float, int, np.ndarray)):
114
+ elif not isinstance(sigma, float | int | np.ndarray):
115
115
  sigma = np.array(sigma)
116
116
 
117
117
  rng = np.random.default_rng(seed=seed) # Create a random number generator
reboost/optmap/cli.py CHANGED
@@ -162,7 +162,7 @@ def optical_cli() -> None:
162
162
  convolve_parser.add_argument(
163
163
  "--material",
164
164
  action="store",
165
- choices=("lar", "pen", "fib"),
165
+ choices=("lar", "pen", "fiber"),
166
166
  default="lar",
167
167
  help="default: %(default)s",
168
168
  )
@@ -2,17 +2,20 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import re
5
+ from typing import NamedTuple
5
6
 
7
+ import awkward as ak
6
8
  import legendoptics.scintillate as sc
7
9
  import numba
8
10
  import numpy as np
9
- import pint
10
- from legendoptics import lar
11
+ from legendoptics import fibers, lar, pen
11
12
  from lgdo import lh5
12
13
  from lgdo.lh5 import LH5Iterator
13
14
  from lgdo.types import Array, Histogram, Table
14
15
  from numba import njit, prange
15
16
  from numpy.lib.recfunctions import structured_to_unstructured
17
+ from numpy.typing import NDArray
18
+ from pint import Quantity
16
19
 
17
20
  from .numba_pdg import numba_pdgid_funcs
18
21
 
@@ -23,7 +26,16 @@ OPTMAP_ANY_CH = -1
23
26
  OPTMAP_SUM_CH = -2
24
27
 
25
28
 
26
- def open_optmap(optmap_fn: str):
29
+ class OptmapForConvolve(NamedTuple):
30
+ """A loaded optmap for convolving."""
31
+
32
+ detids: NDArray
33
+ detidx: NDArray
34
+ edges: NDArray
35
+ weights: NDArray
36
+
37
+
38
+ def open_optmap(optmap_fn: str) -> OptmapForConvolve:
27
39
  maps = lh5.ls(optmap_fn)
28
40
  # only accept _<number> (/all is read separately)
29
41
  det_ntuples = [m for m in maps if re.match(r"_\d+$", m)]
@@ -37,7 +49,7 @@ def open_optmap(optmap_fn: str):
37
49
  ow = np.empty((detidx.shape[0] + 2, *optmap_all.weights.nda.shape), dtype=np.float64)
38
50
  # 0, ..., len(detidx)-1 AND OPTMAP_ANY_CH might be negative.
39
51
  ow[OPTMAP_ANY_CH] = optmap_all.weights.nda
40
- for i, nt in zip(detidx, det_ntuples):
52
+ for i, nt in zip(detidx, det_ntuples, strict=True):
41
53
  optmap = lh5.read(f"/{nt}/p_det", optmap_fn)
42
54
  assert isinstance(optmap, Histogram)
43
55
  ow[i] = optmap.weights.nda
@@ -69,15 +81,35 @@ def open_optmap(optmap_fn: str):
69
81
  if np.isfinite(optmap_multi_det_exp):
70
82
  msg = f"found finite _hitcounts_exp {optmap_multi_det_exp} which is not supported any more"
71
83
  raise RuntimeError(msg)
72
- except KeyError: # the _hitcounts_exp might not be always present.
84
+ except lh5.exceptions.LH5DecodeError: # the _hitcounts_exp might not be always present.
73
85
  pass
74
86
 
75
- return detids, detidx, optmap_edges, ow
87
+ return OptmapForConvolve(detids, detidx, optmap_edges, ow)
88
+
89
+
90
+ def open_optmap_single(optmap_fn: str, spm_det_uid: int) -> OptmapForConvolve:
91
+ try:
92
+ # check the exponent from the optical map file
93
+ optmap_multi_det_exp = lh5.read("/_hitcounts_exp", optmap_fn).value
94
+ assert isinstance(optmap_multi_det_exp, float)
95
+ if np.isfinite(optmap_multi_det_exp):
96
+ msg = f"found finite _hitcounts_exp {optmap_multi_det_exp} which is not supported any more"
97
+ raise RuntimeError(msg)
98
+ except lh5.exceptions.LH5DecodeError: # the _hitcounts_exp might not be always present.
99
+ pass
100
+
101
+ optmap = lh5.read(f"/_{spm_det_uid}/p_det", optmap_fn)
102
+ assert isinstance(optmap, Histogram)
103
+ ow = np.empty((1, *optmap.weights.nda.shape), dtype=np.float64)
104
+ ow[0] = optmap.weights.nda
105
+ optmap_edges = tuple([b.edges for b in optmap.binning])
106
+
107
+ return OptmapForConvolve(np.array([spm_det_uid]), np.array([0]), optmap_edges, ow)
76
108
 
77
109
 
78
110
  def iterate_stepwise_depositions(
79
111
  edep_df: np.rec.recarray,
80
- optmap_for_convolve,
112
+ optmap: OptmapForConvolve,
81
113
  scint_mat_params: sc.ComputedScintParams,
82
114
  rng: np.random.Generator = None,
83
115
  dist: str = "poisson",
@@ -95,7 +127,17 @@ def iterate_stepwise_depositions(
95
127
 
96
128
  rng = np.random.default_rng() if rng is None else rng
97
129
  output_map, res = _iterate_stepwise_depositions(
98
- edep_df, x0, x1, rng, *optmap_for_convolve, scint_mat_params, dist, mode
130
+ edep_df,
131
+ x0,
132
+ x1,
133
+ rng,
134
+ optmap.detids,
135
+ optmap.detidx,
136
+ optmap.edges,
137
+ optmap.weights,
138
+ scint_mat_params,
139
+ dist,
140
+ mode,
99
141
  )
100
142
  if res["any_no_stats"] > 0 or res["det_no_stats"] > 0:
101
143
  log.warning(
@@ -120,6 +162,78 @@ def iterate_stepwise_depositions(
120
162
  return output_map
121
163
 
122
164
 
165
+ def iterate_stepwise_depositions_pois(
166
+ edep_hits: ak.Array,
167
+ optmap: OptmapForConvolve,
168
+ scint_mat_params: sc.ComputedScintParams,
169
+ det_uid: int,
170
+ map_scaling: float = 1,
171
+ rng: np.random.Generator | None = None,
172
+ ):
173
+ if edep_hits.particle.ndim == 1:
174
+ msg = "the pe processors only support already reshaped output"
175
+ raise ValueError(msg)
176
+
177
+ rng = np.random.default_rng() if rng is None else rng
178
+ res, output_list = _iterate_stepwise_depositions_pois(
179
+ edep_hits,
180
+ rng,
181
+ np.where(optmap.detids == det_uid)[0][0],
182
+ map_scaling,
183
+ optmap.edges,
184
+ optmap.weights,
185
+ scint_mat_params,
186
+ )
187
+
188
+ # convert the numba result back into an awkward array.
189
+ builder = ak.ArrayBuilder()
190
+ for r in output_list:
191
+ with builder.list():
192
+ for a in r:
193
+ builder.extend(a)
194
+
195
+ if res["det_no_stats"] > 0:
196
+ log.warning(
197
+ "had edep out in voxels without stats: %d",
198
+ res["det_no_stats"],
199
+ )
200
+ if res["oob"] > 0:
201
+ log.warning(
202
+ "had edep out of map bounds: %d (%.2f%%)",
203
+ res["oob"],
204
+ (res["oob"] / (res["ib"] + res["oob"])) * 100,
205
+ )
206
+ log.debug(
207
+ "VUV_primary %d ->hits %d (%.2f %% primaries detected in this channel)",
208
+ res["vuv_primary"],
209
+ res["hits"],
210
+ (res["hits"] / res["vuv_primary"]) * 100,
211
+ )
212
+ return builder.snapshot()
213
+
214
+
215
+ def iterate_stepwise_depositions_scintillate(
216
+ edep_hits: ak.Array,
217
+ scint_mat_params: sc.ComputedScintParams,
218
+ rng: np.random.Generator | None = None,
219
+ mode: str = "no-fano",
220
+ ):
221
+ if edep_hits.particle.ndim == 1:
222
+ msg = "the pe processors only support already reshaped output"
223
+ raise ValueError(msg)
224
+
225
+ rng = np.random.default_rng() if rng is None else rng
226
+ output_list = _iterate_stepwise_depositions_scintillate(edep_hits, rng, scint_mat_params, mode)
227
+
228
+ # convert the numba result back into an awkward array.
229
+ builder = ak.ArrayBuilder()
230
+ for r in output_list:
231
+ with builder.list():
232
+ builder.extend(r)
233
+
234
+ return builder.snapshot()
235
+
236
+
123
237
  _pdg_func = numba_pdgid_funcs()
124
238
 
125
239
 
@@ -318,6 +432,117 @@ def _iterate_stepwise_depositions(
318
432
  return output_map, stats
319
433
 
320
434
 
435
+ # - run with NUMBA_FULL_TRACEBACKS=1 NUMBA_BOUNDSCHECK=1 for testing/checking
436
+ # - cache=True does not work with outer prange, i.e. loading the cached file fails (numba bug?)
437
+ # - the output dictionary is not threadsafe, so parallel=True is not working with it.
438
+ @njit(parallel=False, nogil=True, cache=True)
439
+ def _iterate_stepwise_depositions_pois(
440
+ edep_hits,
441
+ rng,
442
+ detidx: int,
443
+ map_scaling: float,
444
+ optmap_edges,
445
+ optmap_weights,
446
+ scint_mat_params: sc.ComputedScintParams,
447
+ ):
448
+ pdgid_map = {}
449
+ oob = ib = ph_cnt = ph_det2 = det_no_stats = 0 # for statistics
450
+ output_list = []
451
+
452
+ for rowid in range(len(edep_hits)): # iterate hits
453
+ hit = edep_hits[rowid]
454
+ hit_output = []
455
+
456
+ assert len(hit.particle) == len(hit.num_scint_ph)
457
+ # iterate steps inside the hit
458
+ for si in range(len(hit.particle)):
459
+ loc = np.array([hit.xloc[si], hit.yloc[si], hit.zloc[si]])
460
+ # coordinates -> bins of the optical map.
461
+ bins = np.empty(3, dtype=np.int64)
462
+ for j in range(3):
463
+ bins[j] = np.digitize(loc[j], optmap_edges[j])
464
+ # normalize all out-of-bounds bins just to one end.
465
+ if bins[j] == optmap_edges[j].shape[0]:
466
+ bins[j] = 0
467
+
468
+ # note: subtract 1 from bins, to account for np.digitize output.
469
+ cur_bins = (bins[0] - 1, bins[1] - 1, bins[2] - 1)
470
+ if cur_bins[0] == -1 or cur_bins[1] == -1 or cur_bins[2] == -1:
471
+ oob += 1
472
+ continue # out-of-bounds of optmap
473
+ ib += 1
474
+
475
+ # get probabilities from map.
476
+ detp = optmap_weights[detidx, cur_bins[0], cur_bins[1], cur_bins[2]] * map_scaling
477
+ if detp < 0.0:
478
+ det_no_stats += 1
479
+ continue
480
+
481
+ pois_cnt = rng.poisson(lam=hit.num_scint_ph[si] * detp)
482
+ ph_cnt += hit.num_scint_ph[si]
483
+ ph_det2 += pois_cnt
484
+
485
+ # get the particle information.
486
+ particle = hit.particle[si]
487
+ if particle not in pdgid_map:
488
+ pdgid_map[particle] = (_pdgid_to_particle(particle), _pdg_func.charge(particle))
489
+ part, _charge = pdgid_map[particle]
490
+
491
+ # get time spectrum.
492
+ # note: we assume "immediate" propagation after scintillation.
493
+ scint_times = sc.scintillate_times(scint_mat_params, part, pois_cnt, rng) + hit.time[si]
494
+
495
+ hit_output.append(scint_times)
496
+
497
+ output_list.append(hit_output)
498
+
499
+ stats = {
500
+ "oob": oob,
501
+ "ib": ib,
502
+ "vuv_primary": ph_cnt,
503
+ "hits": ph_det2,
504
+ "det_no_stats": det_no_stats,
505
+ }
506
+ return stats, output_list
507
+
508
+
509
+ # - run with NUMBA_FULL_TRACEBACKS=1 NUMBA_BOUNDSCHECK=1 for testing/checking
510
+ # - cache=True does not work with outer prange, i.e. loading the cached file fails (numba bug?)
511
+ @njit(parallel=False, nogil=True, cache=True)
512
+ def _iterate_stepwise_depositions_scintillate(
513
+ edep_hits, rng, scint_mat_params: sc.ComputedScintParams, mode: str
514
+ ):
515
+ pdgid_map = {}
516
+ output_list = []
517
+
518
+ for rowid in range(len(edep_hits)): # iterate hits
519
+ hit = edep_hits[rowid]
520
+ hit_output = []
521
+
522
+ # iterate steps inside the hit
523
+ for si in range(len(hit.particle)):
524
+ # get the particle information.
525
+ particle = hit.particle[si]
526
+ if particle not in pdgid_map:
527
+ pdgid_map[particle] = (_pdgid_to_particle(particle), _pdg_func.charge(particle))
528
+ part, _charge = pdgid_map[particle]
529
+
530
+ # do the scintillation.
531
+ num_phot = sc.scintillate_numphot(
532
+ scint_mat_params,
533
+ part,
534
+ hit.edep[si],
535
+ rng,
536
+ emission_term_model=("poisson" if mode == "no-fano" else "normal_fano"),
537
+ )
538
+ hit_output.append(num_phot)
539
+
540
+ assert len(hit_output) == len(hit.particle)
541
+ output_list.append(hit_output)
542
+
543
+ return output_list
544
+
545
+
321
546
  def get_output_table(output_map):
322
547
  ph_count_o = 0
323
548
  for _rawid, (_evtid, det, _times) in output_map.items():
@@ -342,25 +567,12 @@ def convolve(
342
567
  map_file: str,
343
568
  edep_file: str,
344
569
  edep_path: str,
345
- material: str,
570
+ material: str | tuple[sc.ScintConfig, tuple[Quantity, ...]],
346
571
  output_file: str | None = None,
347
572
  buffer_len: int = int(1e6),
348
573
  dist_mode: str = "poisson+no-fano",
349
574
  ):
350
- if material not in ["lar", "pen"]:
351
- msg = f"unknown material {material} for scintillation"
352
- raise ValueError(msg)
353
-
354
- if material == "lar":
355
- scint_mat_params = sc.precompute_scintillation_params(
356
- lar.lar_scintillation_params(),
357
- lar.lar_lifetimes().as_tuple(),
358
- )
359
- elif material == "pen":
360
- scint_mat_params = sc.precompute_scintillation_params(
361
- lar.pen_scintillation_params(),
362
- (1 * pint.get_application_registry().ns), # dummy!
363
- )
575
+ scint_mat_params = _get_scint_params(material)
364
576
 
365
577
  # special handling of distributions and flags.
366
578
  dist, mode = dist_mode.split("+")
@@ -379,7 +591,7 @@ def convolve(
379
591
  it = LH5Iterator(edep_file, edep_path, buffer_len=buffer_len)
380
592
 
381
593
  for it_count, edep_lgdo in enumerate(it):
382
- edep_df = edep_lgdo.view_as("pd").to_records()
594
+ edep_df = _reflatten_scint_vov(edep_lgdo.view_as("ak")).to_numpy()
383
595
 
384
596
  log.info("start event processing (%d)", it_count)
385
597
  output_map = iterate_stepwise_depositions(
@@ -393,3 +605,37 @@ def convolve(
393
605
  )
394
606
  if output_file is not None:
395
607
  lh5.write(tbl, "optical", lh5_file=output_file, group="stp", wo_mode="append")
608
+
609
+
610
+ def _reflatten_scint_vov(arr: ak.Array) -> ak.Array:
611
+ if all(arr[f].ndim == 1 for f in ak.fields(arr)):
612
+ return arr
613
+
614
+ group_num = ak.num(arr["edep"]).to_numpy()
615
+ flattened = {
616
+ f: ak.flatten(arr[f]) if arr[f].ndim > 1 else np.repeat(arr[f].to_numpy(), group_num)
617
+ for f in ak.fields(arr)
618
+ }
619
+ return ak.Array(flattened)
620
+
621
+
622
+ def _get_scint_params(material: str):
623
+ if material == "lar":
624
+ return sc.precompute_scintillation_params(
625
+ lar.lar_scintillation_params(),
626
+ lar.lar_lifetimes().as_tuple(),
627
+ )
628
+ if material == "pen":
629
+ return sc.precompute_scintillation_params(
630
+ pen.pen_scintillation_params(),
631
+ (pen.pen_scint_timeconstant(),),
632
+ )
633
+ if material == "fiber":
634
+ return sc.precompute_scintillation_params(
635
+ fibers.fiber_core_scintillation_params(),
636
+ (fibers.fiber_wls_timeconstant(),),
637
+ )
638
+ if isinstance(material, str):
639
+ msg = f"unknown material {material} for scintillation"
640
+ raise ValueError(msg)
641
+ return sc.precompute_scintillation_params(*material)
reboost/optmap/create.py CHANGED
@@ -4,8 +4,9 @@ import copy
4
4
  import gc
5
5
  import logging
6
6
  import multiprocessing as mp
7
+ from collections.abc import Callable
7
8
  from pathlib import Path
8
- from typing import Callable, Literal
9
+ from typing import Literal
9
10
 
10
11
  import numpy as np
11
12
  import scipy.optimize
reboost/optmap/optmap.py CHANGED
@@ -112,7 +112,7 @@ class OpticalMap:
112
112
  idx = np.zeros(xyz.shape[1], np.int64) # bin indices for flattened array
113
113
  oor_mask = np.ones(xyz.shape[1], np.bool_) # mask to remove out of range values
114
114
  dims = range(xyz.shape[0])
115
- for col, ax, s, dim in zip(xyz, self.binning, self._single_stride, dims):
115
+ for col, ax, s, dim in zip(xyz, self.binning, self._single_stride, dims, strict=True):
116
116
  assert ax.is_range
117
117
  assert ax.closedleft
118
118
  oor_mask &= (ax.first <= col) & (col < ax.last)
@@ -326,4 +326,4 @@ class OpticalMap:
326
326
  assert all(isinstance(b, np.ndarray) for b in e1)
327
327
  assert all(isinstance(b, np.ndarray) for b in e2)
328
328
 
329
- return len(e1) == len(e2) and all(np.all(x1 == x2) for x1, x2 in zip(e1, e2))
329
+ return all(np.all(x1 == x2) for x1, x2 in zip(e1, e2, strict=True))
reboost/shape/cluster.py CHANGED
@@ -97,9 +97,9 @@ def cluster_by_step_length(
97
97
 
98
98
  pos = np.vstack(
99
99
  [
100
- ak.flatten(pos_x).to_numpy(),
101
- ak.flatten(pos_y).to_numpy(),
102
- ak.flatten(pos_z).to_numpy(),
100
+ ak.flatten(pos_x).to_numpy().astype(np.float64),
101
+ ak.flatten(pos_y).to_numpy().astype(np.float64),
102
+ ak.flatten(pos_z).to_numpy().astype(np.float64),
103
103
  ]
104
104
  ).T
105
105
 
@@ -164,7 +164,7 @@ def cluster_by_distance_numba(
164
164
  return np.sqrt(np.sum((a - b) ** 2))
165
165
 
166
166
  n = len(local_index)
167
- out = np.zeros(n, dtype=numba.int32)
167
+ out = np.zeros((n,), dtype=numba.int32)
168
168
 
169
169
  trackid_prev = -1
170
170
  pos_prev = np.zeros(3, dtype=numba.float64)
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .pe import detected_photoelectrons, emitted_scintillation_photons, load_optmap
4
+
5
+ __all__ = ["detected_photoelectrons", "emitted_scintillation_photons", "load_optmap"]
reboost/spms/pe.py ADDED
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ import awkward as ak
6
+ from lgdo import VectorOfVectors
7
+
8
+ from ..optmap import convolve
9
+ from ..units import units_conv_ak
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ def load_optmap_all(map_file: str) -> convolve.OptmapForConvolve:
15
+ """Load an optical map file for later use with :py:func:`detected_photoelectrons`."""
16
+ return convolve.open_optmap(map_file)
17
+
18
+
19
+ def load_optmap(map_file: str, spm_det_uid: int) -> convolve.OptmapForConvolve:
20
+ """Load an optical map file for later use with :py:func:`detected_photoelectrons`."""
21
+ return convolve.open_optmap_single(map_file, spm_det_uid)
22
+
23
+
24
+ def detected_photoelectrons(
25
+ num_scint_ph: ak.Array,
26
+ particle: ak.Array,
27
+ time: ak.Array,
28
+ xloc: ak.Array,
29
+ yloc: ak.Array,
30
+ zloc: ak.Array,
31
+ optmap: convolve.OptmapForConvolve,
32
+ material: str,
33
+ spm_detector_uid: int,
34
+ map_scaling: float = 1,
35
+ ) -> VectorOfVectors:
36
+ """Derive the number of detected photoelectrons (p.e.) from scintillator hits using an optical map.
37
+
38
+ Parameters
39
+ ----------
40
+ num_scint_ph
41
+ array of emitted scintillation photons, as generated by
42
+ :func:`emitted_scintillation_photons`.
43
+ particle
44
+ array of particle PDG IDs of scintillation events.
45
+ time
46
+ array of timestamps of scintillation events.
47
+ xloc
48
+ array of x coordinate position of scintillation events.
49
+ yloc
50
+ array of y coordinate position of scintillation events.
51
+ zloc
52
+ array of z coordinate position of scintillation events.
53
+ optmap
54
+ the optical map loaded via py:func:`load_optmap`.
55
+ material
56
+ scintillating material name.
57
+ spm_detector_uid
58
+ SiPM detector uid as used in the optical map.
59
+ map_scaling
60
+ scale the detection probability in the map for this detector by this factor.
61
+ """
62
+ hits = ak.Array(
63
+ {
64
+ "num_scint_ph": num_scint_ph,
65
+ "particle": particle,
66
+ "time": units_conv_ak(time, "ns"),
67
+ "xloc": units_conv_ak(xloc, "m"),
68
+ "yloc": units_conv_ak(yloc, "m"),
69
+ "zloc": units_conv_ak(zloc, "m"),
70
+ }
71
+ )
72
+
73
+ scint_mat_params = convolve._get_scint_params(material)
74
+ pe = convolve.iterate_stepwise_depositions_pois(
75
+ hits, optmap, scint_mat_params, spm_detector_uid, map_scaling
76
+ )
77
+
78
+ return VectorOfVectors(pe, attrs={"units": "ns"})
79
+
80
+
81
+ def emitted_scintillation_photons(
82
+ edep: ak.Array, particle: ak.Array, material: str
83
+ ) -> VectorOfVectors:
84
+ """Derive the number of emitted scintillation photons from scintillator hits.
85
+
86
+ Parameters
87
+ ----------
88
+ edep
89
+ array of deposited energy in scintillation events.
90
+ particle
91
+ array of particle PDG IDs of scintillation events.
92
+ material
93
+ scintillating material name.
94
+ """
95
+ hits = ak.Array({"edep": units_conv_ak(edep, "keV"), "particle": particle})
96
+
97
+ scint_mat_params = convolve._get_scint_params(material)
98
+ ph = convolve.iterate_stepwise_depositions_scintillate(hits, scint_mat_params)
99
+ return VectorOfVectors(ph)
reboost/units.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import logging
4
4
  from typing import Any
5
5
 
6
+ import awkward as ak
6
7
  import pint
7
8
  import pyg4ometry as pg4
8
9
  from lgdo import LGDO
@@ -16,7 +17,7 @@ ureg = pint.get_application_registry()
16
17
  ureg.formatter.default_format = "~P"
17
18
 
18
19
 
19
- def pg4_to_pint(obj) -> pint.Quantity:
20
+ def pg4_to_pint(obj: pint.Quantity | pg4.gdml.Defines.VectorBase) -> pint.Quantity:
20
21
  """Convert pyg4ometry object to pint Quantity."""
21
22
  if isinstance(obj, pint.Quantity):
22
23
  return obj
@@ -26,32 +27,54 @@ def pg4_to_pint(obj) -> pint.Quantity:
26
27
  raise ValueError(msg)
27
28
 
28
29
 
29
- def units_convfact(data: Any, target_units: pint.Units) -> float:
30
+ def units_convfact(data: Any | LGDO | ak.Array, target_units: pint.Unit | str) -> float:
30
31
  """Calculate numeric conversion factor to reach `target_units`.
31
32
 
32
33
  Parameters
33
34
  ----------
34
35
  data
35
- starting data structure. If an LGDO, try to determine units by peeking
36
- into its attributes. Otherwise, just return 1.
36
+ starting data structure. If an :class:`LGDO` or :class:`ak.Array`, try to
37
+ determine units by peeking into its attributes. Otherwise, just return 1.
37
38
  target_units
38
39
  units you wish to convert data to.
39
40
  """
40
41
  if isinstance(data, LGDO) and "units" in data.attrs:
41
42
  return ureg(data.attrs["units"]).to(target_units).magnitude
43
+ if isinstance(data, ak.Array) and "units" in ak.parameters(data):
44
+ return ureg(ak.parameters(data)["units"]).to(target_units).magnitude
42
45
  return 1
43
46
 
44
47
 
45
- def unwrap_lgdo(data: Any, library: str = "ak") -> tuple(Any, pint.Unit | None):
48
+ def units_conv_ak(data: Any | LGDO | ak.Array, target_units: pint.Unit | str) -> ak.Array:
49
+ """Calculate numeric conversion factor to reach `target_units`, and apply to data converted to ak.
50
+
51
+ Parameters
52
+ ----------
53
+ data
54
+ starting data structure. If an :class:`LGDO` or :class:`ak.Array`, try to
55
+ determine units by peeking into its attributes. Otherwise, return the data
56
+ unchanged.
57
+ target_units
58
+ units you wish to convert data to.
59
+ """
60
+ fact = units_convfact(data, target_units)
61
+ if isinstance(data, LGDO) and fact != 1:
62
+ return ak.without_parameters(data.view_as("ak") * fact)
63
+ if isinstance(data, ak.Array) and fact != 1:
64
+ return ak.without_parameters(data * fact)
65
+ return data.view_as("ak") if isinstance(data, LGDO) else data
66
+
67
+
68
+ def unwrap_lgdo(data: Any | LGDO | ak.Array, library: str = "ak") -> tuple[Any, pint.Unit | None]:
46
69
  """Return a view of the data held by the LGDO and its physical units.
47
70
 
48
71
  Parameters
49
72
  ----------
50
73
  data
51
- the data container. If not an LGDO, it will be returned as is with
52
- ``None`` units.
74
+ the data container. If not an :class:`LGDO` or :class:`ak.Array`, it will be
75
+ returned as is with ``None`` units.
53
76
  library
54
- forwarded to :func:`lgdo.view_as`.
77
+ forwarded to :meth:`LGDO.view_as`.
55
78
 
56
79
  Returns
57
80
  -------
@@ -64,6 +87,15 @@ def unwrap_lgdo(data: Any, library: str = "ak") -> tuple(Any, pint.Unit | None):
64
87
  if "units" in data.attrs:
65
88
  ret_units = ureg(data.attrs["units"]).u
66
89
 
90
+ if isinstance(data, ak.Array):
91
+ if library != "ak":
92
+ msg = "cannot unwrap an awkward array as a non-awkward type"
93
+ raise ValueError(msg)
94
+
95
+ if "units" in ak.parameters(data):
96
+ ret_units = ureg(ak.parameters(data)["units"]).u
97
+ ret_data = ak.without_parameters(data)
98
+
67
99
  return ret_data, ret_units
68
100
 
69
101