legend-dataflow-scripts 0.4.0a1__tar.gz → 0.4.0a3__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 (51) hide show
  1. {legend_dataflow_scripts-0.4.0a1/src/legend_dataflow_scripts.egg-info → legend_dataflow_scripts-0.4.0a3}/PKG-INFO +1 -1
  2. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3/src/legend_dataflow_scripts.egg-info}/PKG-INFO +1 -1
  3. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legend_dataflow_scripts.egg-info/scm_file_list.json +1 -0
  4. legend_dataflow_scripts-0.4.0a3/src/legend_dataflow_scripts.egg-info/scm_version.json +8 -0
  5. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/_version.py +3 -3
  6. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/dsp/dplms.py +3 -0
  7. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/dsp/eopt.py +3 -0
  8. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/dsp/evtsel.py +39 -10
  9. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/dsp/nopt.py +3 -0
  10. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/dsp/pz.py +3 -0
  11. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/hit/aoe.py +4 -1
  12. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/hit/ecal.py +8 -8
  13. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/hit/lq.py +5 -2
  14. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/hit/qc.py +3 -0
  15. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/tier/dsp.py +9 -6
  16. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/utils/alias_table.py +37 -27
  17. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/utils/cfgtools.py +4 -2
  18. legend_dataflow_scripts-0.4.0a3/src/legenddataflowscripts/workflow/pre_compile_catalog.py +117 -0
  19. legend_dataflow_scripts-0.4.0a1/src/legend_dataflow_scripts.egg-info/scm_version.json +0 -8
  20. legend_dataflow_scripts-0.4.0a1/src/legenddataflowscripts/workflow/pre_compile_catalog.py +0 -44
  21. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/LICENSE +0 -0
  22. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/README.md +0 -0
  23. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/pyproject.toml +0 -0
  24. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/setup.cfg +0 -0
  25. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legend_dataflow_scripts.egg-info/SOURCES.txt +0 -0
  26. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legend_dataflow_scripts.egg-info/dependency_links.txt +0 -0
  27. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legend_dataflow_scripts.egg-info/entry_points.txt +0 -0
  28. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legend_dataflow_scripts.egg-info/not-zip-safe +0 -0
  29. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legend_dataflow_scripts.egg-info/requires.txt +0 -0
  30. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legend_dataflow_scripts.egg-info/top_level.txt +0 -0
  31. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/__init__.py +0 -0
  32. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/__init__.py +0 -0
  33. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/__init__.py +0 -0
  34. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/dsp/__init__.py +0 -0
  35. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/dsp/svm.py +0 -0
  36. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/dsp/svm_build.py +0 -0
  37. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/par/geds/hit/__init__.py +0 -0
  38. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/tier/__init__.py +0 -0
  39. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/tier/hit.py +0 -0
  40. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/utils/__init__.py +0 -0
  41. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/utils/convert_np.py +0 -0
  42. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/utils/discharge.py +0 -0
  43. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/utils/files.py +0 -0
  44. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/utils/lgdo_utils.py +0 -0
  45. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/utils/log.py +0 -0
  46. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/utils/plot_dict.py +0 -0
  47. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/utils/pulser_removal.py +0 -0
  48. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/workflow/__init__.py +0 -0
  49. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/workflow/execenv.py +0 -0
  50. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/workflow/filedb.py +0 -0
  51. {legend_dataflow_scripts-0.4.0a1 → legend_dataflow_scripts-0.4.0a3}/src/legenddataflowscripts/workflow/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: legend-dataflow-scripts
3
- Version: 0.4.0a1
3
+ Version: 0.4.0a3
4
4
  Summary: Python package for the processing scripts for LEGEND-200 data
5
5
  Author-email: George Marshall <ggmarsh@uw.edu>, Luigi Pertoldi <gipert@pm.me>
6
6
  Maintainer: The LEGEND Collaboration
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: legend-dataflow-scripts
3
- Version: 0.4.0a1
3
+ Version: 0.4.0a3
4
4
  Summary: Python package for the processing scripts for LEGEND-200 data
5
5
  Author-email: George Marshall <ggmarsh@uw.edu>, Luigi Pertoldi <gipert@pm.me>
6
6
  Maintainer: The LEGEND Collaboration
@@ -60,6 +60,7 @@
60
60
  "tests/test_execenv.py",
61
61
  "tests/test_lgdo_utils.py",
62
62
  "tests/test_peak_selection.py",
63
+ "tests/test_pre_compile_catalog.py",
63
64
  "tests/test_util.py",
64
65
  "tests/test_utils_helpers.py"
65
66
  ]
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.4.0a3",
3
+ "distance": 0,
4
+ "node": "gefba089d925c92cfa07af5e93737d253ec830fa5",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-16"
8
+ }
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.0a1'
22
- __version_tuple__ = version_tuple = (0, 4, 0, 'a1')
21
+ __version__ = version = '0.4.0a3'
22
+ __version_tuple__ = version_tuple = (0, 4, 0, 'a3')
23
23
 
24
- __commit_id__ = commit_id = 'g30429c604'
24
+ __commit_id__ = commit_id = 'gefba089d9'
@@ -6,6 +6,7 @@ import time
6
6
  from pathlib import Path
7
7
 
8
8
  import lh5
9
+ import matplotlib as mpl
9
10
  import numpy as np
10
11
  import pygama.math.distributions as pmd # noqa: F401
11
12
  from dbetto.catalog import Props
@@ -25,6 +26,8 @@ from ....utils import (
25
26
  take_table_rows,
26
27
  )
27
28
 
29
+ mpl.use("agg") # no display needed; avoids X11 when DISPLAY is set
30
+
28
31
 
29
32
  def par_geds_dsp_dplms() -> None:
30
33
  """Compute DPLMS optimal filter coefficients for HPGe detectors.
@@ -7,6 +7,7 @@ import warnings
7
7
  from pathlib import Path
8
8
 
9
9
  import lh5
10
+ import matplotlib as mpl
10
11
  import numpy as np
11
12
  import pygama.pargen.energy_optimisation as om # noqa: F401
12
13
  import sklearn.gaussian_process.kernels as ker
@@ -27,6 +28,8 @@ from ....utils import (
27
28
  require_peaks_present,
28
29
  )
29
30
 
31
+ mpl.use("agg") # no display needed; avoids X11 when DISPLAY is set
32
+
30
33
  warnings.filterwarnings(action="ignore", category=RuntimeWarning)
31
34
  try:
32
35
  warnings.filterwarnings(action="ignore", category=np.exceptions.RankWarning)
@@ -133,6 +133,34 @@ def get_out_data(
133
133
  return out_tbl, int(np.count_nonzero(final_mask))
134
134
 
135
135
 
136
+ def initial_bin_width(energy):
137
+ """
138
+ Freedman-Diaconis starting bin width for the peak-finding histogram.
139
+
140
+ Parameters
141
+ ----------
142
+ energy : numpy.ndarray
143
+ uncalibrated energies of the candidate events.
144
+
145
+ Returns
146
+ -------
147
+ float or None
148
+ bin width capped at 2 ADC, or ``None`` when the sample is degenerate
149
+ (fewer than two finite values, or no spread) and no histogram can be built.
150
+ """
151
+ energy = energy[np.isfinite(energy)]
152
+ if len(energy) < 2:
153
+ return None
154
+ width = (
155
+ 2
156
+ * (np.percentile(energy, 75) - np.percentile(energy, 25))
157
+ * len(energy) ** (-1 / 3)
158
+ )
159
+ if not width > 0:
160
+ return None
161
+ return min(width, 2)
162
+
163
+
136
164
  def build_peak_dicts(peaks_kev, kev_widths, masks, log=None):
137
165
  """Build the per-peak accumulator state for the file x peak read loop.
138
166
 
@@ -455,16 +483,17 @@ def par_geds_dsp_evtsel() -> None:
455
483
  )
456
484
  energy = tb_out[energy_parameter].nda
457
485
 
458
- init_bin_width = (
459
- 2
460
- * (
461
- np.nanpercentile(energy, 75)
462
- - np.nanpercentile(energy, 25)
486
+ init_bin_width = initial_bin_width(energy)
487
+ if init_bin_width is None: # too few events to histogram
488
+ msg = (
489
+ f"{peak}: only {np.isfinite(energy).sum()} "
490
+ "usable events, skipping peak"
463
491
  )
464
- * len(energy) ** (-1 / 3)
465
- )
466
-
467
- init_bin_width = min(init_bin_width, 2)
492
+ log.warning(msg)
493
+ peak_dict["obj_buf"] = None
494
+ peak_dict["obj_buf_start"] = 0
495
+ peak_dict["idxs"] = None
496
+ continue
468
497
 
469
498
  hist, bins, var = pgh.get_hist(
470
499
  energy,
@@ -487,7 +516,7 @@ def par_geds_dsp_evtsel() -> None:
487
516
  mu = peak_top_pars[0]
488
517
  if mu > np.nanmax(bins) or mu < np.nanmin(bins):
489
518
  raise ValueError
490
- except Exception:
519
+ except (ValueError, TypeError, IndexError):
491
520
  mu = np.nan
492
521
  if mu is None or np.isnan(mu):
493
522
  log.debug("Fit failed, using max guess")
@@ -6,6 +6,7 @@ import time
6
6
  from pathlib import Path
7
7
 
8
8
  import lh5
9
+ import matplotlib as mpl
9
10
  import numpy as np
10
11
  import pygama.pargen.noise_optimization as pno
11
12
  from dbetto.catalog import Props
@@ -20,6 +21,8 @@ from ....utils import (
20
21
  require_config_keys,
21
22
  )
22
23
 
24
+ mpl.use("agg") # no display needed; avoids X11 when DISPLAY is set
25
+
23
26
 
24
27
  def par_geds_dsp_nopt() -> None:
25
28
  """Optimise DSP filter parameters for noise performance.
@@ -6,6 +6,7 @@ import pickle as pkl
6
6
  from pathlib import Path
7
7
 
8
8
  import lh5
9
+ import matplotlib as mpl
9
10
  import numpy as np
10
11
  from dbetto.catalog import Props
11
12
  from dspeed import build_dsp
@@ -24,6 +25,8 @@ from ....utils import (
24
25
  take_table_rows,
25
26
  )
26
27
 
28
+ mpl.use("agg") # no display needed; avoids X11 when DISPLAY is set
29
+
27
30
 
28
31
  def par_geds_dsp_pz() -> None:
29
32
  """Determine the pole-zero (PZ) decay constant(s) for HPGe waveforms.
@@ -10,9 +10,10 @@ import time
10
10
  import warnings
11
11
  from pathlib import Path
12
12
 
13
+ import matplotlib as mpl
13
14
  import numpy as np
14
15
  from dbetto.catalog import Props
15
- from pygama.pargen.AoE_cal import * # noqa: F403
16
+ from pygama.pargen.AoE_cal import *
16
17
  from pygama.pargen.AoE_cal import CalAoE
17
18
  from pygama.pargen.utils import load_data
18
19
 
@@ -28,6 +29,8 @@ from ....utils import (
28
29
  require_unique_suffixes,
29
30
  )
30
31
 
32
+ mpl.use("agg") # no display needed; avoids X11 when DISPLAY is set
33
+
31
34
  log = logging.getLogger(__name__)
32
35
 
33
36
  warnings.filterwarnings(action="ignore", category=RuntimeWarning)
@@ -5,7 +5,7 @@ import copy
5
5
  import inspect
6
6
  import pickle as pkl
7
7
  import warnings
8
- from datetime import datetime
8
+ from datetime import UTC, datetime
9
9
  from pathlib import Path
10
10
 
11
11
  import lh5
@@ -104,14 +104,14 @@ def plot_2614_timemap(
104
104
 
105
105
  ticks, _ = plt.xticks()
106
106
  plt.xlabel(
107
- f"Time starting : {datetime.utcfromtimestamp(ticks[0]).strftime('%d/%m/%y %H:%M')}"
107
+ f"Time starting : {datetime.fromtimestamp(ticks[0], tz=UTC).strftime('%d/%m/%y %H:%M')}"
108
108
  )
109
109
  plt.ylabel("Energy(keV)")
110
110
  plt.ylim([erange[0], erange[1]])
111
111
 
112
112
  plt.xticks(
113
113
  ticks,
114
- [datetime.utcfromtimestamp(tick).strftime("%H:%M") for tick in ticks],
114
+ [datetime.fromtimestamp(tick, tz=UTC).strftime("%H:%M") for tick in ticks],
115
115
  )
116
116
  plt.close()
117
117
  return fig
@@ -188,13 +188,13 @@ def plot_pulser_timemap(
188
188
  plt.ylim([mean - n_spread * spread, mean + n_spread * spread])
189
189
  ticks, _ = plt.xticks()
190
190
  plt.xlabel(
191
- f"Time starting : {datetime.utcfromtimestamp(ticks[0]).strftime('%d/%m/%y %H:%M')}"
191
+ f"Time starting : {datetime.fromtimestamp(ticks[0], tz=UTC).strftime('%d/%m/%y %H:%M')}"
192
192
  )
193
193
  plt.ylabel("Energy(keV)")
194
194
 
195
195
  plt.xticks(
196
196
  ticks,
197
- [datetime.utcfromtimestamp(tick).strftime("%H:%M") for tick in ticks],
197
+ [datetime.fromtimestamp(tick, tz=UTC).strftime("%H:%M") for tick in ticks],
198
198
  )
199
199
  plt.close()
200
200
  return fig
@@ -488,14 +488,14 @@ def plot_baseline_timemap(
488
488
 
489
489
  ticks, _ = plt.xticks()
490
490
  plt.xlabel(
491
- f"Time starting : {datetime.utcfromtimestamp(ticks[0]).strftime('%d/%m/%y %H:%M')}"
491
+ f"Time starting : {datetime.fromtimestamp(ticks[0], tz=UTC).strftime('%d/%m/%y %H:%M')}"
492
492
  )
493
493
  plt.ylabel("Baseline Value")
494
494
  plt.ylim([mean - n_spread * spread, mean + n_spread * spread])
495
495
 
496
496
  plt.xticks(
497
497
  ticks,
498
- [datetime.utcfromtimestamp(tick).strftime("%H:%M") for tick in ticks],
498
+ [datetime.fromtimestamp(tick, tz=UTC).strftime("%H:%M") for tick in ticks],
499
499
  )
500
500
  plt.close()
501
501
  return fig
@@ -683,7 +683,7 @@ def get_results_dict(ecal_class, data, cal_energy_param, selection_string):
683
683
 
684
684
  pk_dict = results_dict["peak_parameters"]
685
685
 
686
- for _, dic in pk_dict.items():
686
+ for dic in pk_dict.values():
687
687
  dic["function"] = dic["function"].name
688
688
  dic["parameters"] = dic["parameters"].to_dict()
689
689
  dic["uncertainties"] = dic["uncertainties"].to_dict()
@@ -9,12 +9,13 @@ import time
9
9
  import warnings
10
10
  from pathlib import Path
11
11
 
12
+ import matplotlib as mpl
12
13
  import numpy as np
13
14
  import pandas as pd
14
15
  from dbetto.catalog import Props
15
16
  from pygama.math.distributions import gaussian
16
- from pygama.pargen.AoE_cal import * # noqa: F403
17
- from pygama.pargen.lq_cal import * # noqa: F403
17
+ from pygama.pargen.AoE_cal import *
18
+ from pygama.pargen.lq_cal import *
18
19
  from pygama.pargen.lq_cal import LQCal
19
20
  from pygama.pargen.utils import load_data
20
21
 
@@ -30,6 +31,8 @@ from ....utils import (
30
31
  require_unique_suffixes,
31
32
  )
32
33
 
34
+ mpl.use("agg") # no display needed; avoids X11 when DISPLAY is set
35
+
33
36
  log = logging.getLogger(__name__)
34
37
  warnings.filterwarnings(action="ignore", category=RuntimeWarning)
35
38
 
@@ -9,6 +9,7 @@ import time
9
9
  import warnings
10
10
  from pathlib import Path
11
11
 
12
+ import matplotlib as mpl
12
13
  import numpy as np
13
14
  from dbetto.catalog import Props
14
15
  from lh5 import ls
@@ -28,6 +29,8 @@ from ....utils import (
28
29
  prepare_output_paths,
29
30
  )
30
31
 
32
+ mpl.use("agg") # no display needed; avoids X11 when DISPLAY is set
33
+
31
34
  log = logging.getLogger(__name__)
32
35
  warnings.filterwarnings(action="ignore", category=RuntimeWarning)
33
36
 
@@ -3,7 +3,8 @@ from __future__ import annotations
3
3
  import argparse
4
4
  import time
5
5
  import warnings
6
- from multiprocessing import Pool
6
+ from concurrent.futures import ProcessPoolExecutor
7
+ from multiprocessing import get_context
7
8
  from pathlib import Path
8
9
 
9
10
  import lh5
@@ -51,7 +52,7 @@ def build_dsp_wrapper(kwargs):
51
52
  """Unpack *kwargs* and delegate to :func:`dspeed.build_dsp`.
52
53
 
53
54
  This thin wrapper exists so that a single-argument callable can be passed
54
- to :meth:`multiprocessing.Pool.map`.
55
+ to :meth:`concurrent.futures.Executor.map`.
55
56
 
56
57
  Parameters
57
58
  ----------
@@ -239,10 +240,12 @@ def build_tier_dsp() -> None:
239
240
  }
240
241
  process_kwargs_list.append(kwargs)
241
242
 
242
- # Create a multiprocessing pool
243
- with Pool(processes=args.n_processes) as pool:
244
- # Use starmap to pass multiple arguments to the process function
245
- pool.map(build_dsp_wrapper, process_kwargs_list)
243
+ # fork pinned: default start method changes in py3.14
244
+ # a killed worker raises BrokenProcessPool here; Pool.map would hang forever
245
+ with ProcessPoolExecutor(
246
+ max_workers=args.n_processes, mp_context=get_context("fork")
247
+ ) as pool:
248
+ list(pool.map(build_dsp_wrapper, process_kwargs_list))
246
249
 
247
250
  # merge the DSPs
248
251
  log.info("Merging DSPs")
@@ -7,7 +7,7 @@ import h5py
7
7
 
8
8
 
9
9
  def convert_parents_to_structs(h5group):
10
- """Recursively annotate HDF5 parent groups with LGDO ``struct`` datatype attributes.
10
+ """Annotate HDF5 parent groups with LGDO ``struct`` datatype attributes.
11
11
 
12
12
  When a new alias (hard link) is created inside an HDF5 file the parent
13
13
  groups may not carry the ``datatype`` attribute expected by the LGDO reader.
@@ -15,38 +15,48 @@ def convert_parents_to_structs(h5group):
15
15
  every ancestor group carries a ``datatype`` attribute of the form
16
16
  ``struct{child1,child2,…}``.
17
17
 
18
+ The root group is only updated if it already carries a ``datatype``; the
19
+ walk always stops there.
20
+
18
21
  Parameters
19
22
  ----------
20
23
  h5group : h5py.Group
21
24
  Leaf group whose parent hierarchy should be annotated.
22
25
  """
23
- child = h5group.name.split("/")[-1]
24
- if h5group.parent.name != "/" and len(h5group.parent.attrs) == 0:
25
- h5group.parent.attrs.update({"datatype": "struct{" + child + "}"})
26
- elif len(h5group.parent.attrs) > 0:
27
- datatype = h5group.parent.attrs["datatype"]
28
- if not (datatype.startswith("struct{") and datatype.endswith("}")):
29
- msg = (
30
- f"cannot register alias {child!r}: parent group "
31
- f"{h5group.parent.name!r} has non-struct datatype {datatype!r}"
26
+ # the walk must be iterative: in h5py the root group is its own parent, so
27
+ # recursing on ``h5group.parent`` never terminates once ``/`` is reached
28
+ while h5group.name != "/":
29
+ parent = h5group.parent
30
+ child = h5group.name.rsplit("/", 1)[-1]
31
+
32
+ if "datatype" not in parent.attrs:
33
+ # never invent a datatype on the file root
34
+ if parent.name == "/":
35
+ return
36
+ parent.attrs.update({"datatype": "struct{" + child + "}"})
37
+ else:
38
+ datatype = parent.attrs["datatype"]
39
+ if not (datatype.startswith("struct{") and datatype.endswith("}")):
40
+ msg = (
41
+ f"cannot register alias {child!r}: parent group "
42
+ f"{parent.name!r} has non-struct datatype {datatype!r}"
43
+ )
44
+ raise ValueError(msg)
45
+ # membership must be tested against the parsed field list, not by
46
+ # substring (e.g. 'raw' is a substring of 'raw_blind')
47
+ fields = [
48
+ field.strip()
49
+ for field in datatype[len("struct{") : -1].split(",")
50
+ if field.strip()
51
+ ]
52
+ if child in fields:
53
+ # ancestors were annotated when this member was first added
54
+ return
55
+ parent.attrs.update(
56
+ {"datatype": "struct{" + ",".join([*fields, child]) + "}"}
32
57
  )
33
- raise ValueError(msg)
34
- # membership must be tested against the parsed field list, not by
35
- # substring (e.g. 'raw' is a substring of 'raw_blind')
36
- fields = [
37
- field.strip()
38
- for field in datatype[len("struct{") : -1].split(",")
39
- if field.strip()
40
- ]
41
- if child in fields:
42
- return
43
- h5group.parent.attrs.update(
44
- {"datatype": "struct{" + ",".join([*fields, child]) + "}"}
45
- )
46
- else:
47
- return
48
- convert_parents_to_structs(h5group.parent)
49
- return
58
+
59
+ h5group = parent
50
60
 
51
61
 
52
62
  def alias_table(file: str | Path, mapping: str):
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import copy
3
4
  from collections.abc import Iterable, Mapping
4
5
  from pathlib import Path
5
6
 
@@ -133,14 +134,15 @@ def get_rule_config(configs_path, rule_name, timestamp, datatype):
133
134
 
134
135
  Wraps ``TextDB(...).on(...)["snakemake_rules"][rule_name]`` so that a
135
136
  missing key names the rule, timestamp, datatype and config path instead
136
- of raising a bare :class:`KeyError`.
137
+ of raising a bare :class:`KeyError`. Returns a writable deep copy, since
138
+ ``TextDB.on()`` results are shared and read-only.
137
139
  """
138
140
  if not Path(configs_path).is_dir():
139
141
  msg = f"config directory {configs_path} does not exist"
140
142
  raise FileNotFoundError(msg)
141
143
  configs = TextDB(configs_path, lazy=True).on(timestamp, category=datatype)
142
144
  try:
143
- return configs["snakemake_rules"][rule_name]
145
+ return copy.deepcopy(configs["snakemake_rules"][rule_name])
144
146
  except KeyError as err:
145
147
  msg = (
146
148
  f"config resolved from {configs_path} for timestamp {timestamp} "
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import pickle
7
+ import tempfile
8
+ from datetime import UTC, datetime
9
+ from importlib.metadata import version
10
+ from pathlib import Path
11
+
12
+ from dbetto import TextDB
13
+ from dbetto.catalog import Catalog
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ def _content_digest(validity_path: Path) -> str:
19
+ """Digest of the directory contents (relative path, mtime and size of
20
+ every file) plus the dbetto version, identifying one compiled state of
21
+ the catalog."""
22
+ state = [version("dbetto")]
23
+ for f in sorted(validity_path.rglob("*")):
24
+ if f.is_file():
25
+ st = f.stat()
26
+ state.append(
27
+ f"{f.relative_to(validity_path)}:{st.st_mtime_ns}:{st.st_size}"
28
+ )
29
+ return hashlib.sha256("\n".join(state).encode()).hexdigest()[:16]
30
+
31
+
32
+ def _cache_dir(validity_path: Path) -> Path:
33
+ """Per-directory cache namespace, keyed on the resolved directory path, so
34
+ same-named directories in different locations cannot evict each other's
35
+ pickles."""
36
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
37
+ dir_key = hashlib.sha256(str(validity_path.resolve()).encode()).hexdigest()[:16]
38
+ return (
39
+ Path(base)
40
+ / "legend-dataflow"
41
+ / "precompiled-catalogs"
42
+ / f"{validity_path.name}-{dir_key}"
43
+ )
44
+
45
+
46
+ def pre_compile_catalog(validity_path: str | Path):
47
+ """Pre-compile a dbetto validity catalog for fast repeated access.
48
+
49
+ Reads the ``validity.yaml`` catalog from *validity_path* and, for each
50
+ system and each entry in the catalog, eagerly loads the corresponding
51
+ :class:`dbetto.TextDB` state (instead of loading it lazily on first
52
+ access). The resulting :class:`dbetto.catalog.Catalog` can be serialised
53
+ and reused across many Snakemake jobs without re-parsing YAML on every
54
+ invocation.
55
+
56
+ The compiled catalog is cached on disk under
57
+ ``$XDG_CACHE_HOME/legend-dataflow/precompiled-catalogs``, in a
58
+ subdirectory per (resolved) input directory, keyed on the directory
59
+ contents (file mtimes/sizes) and the dbetto version, so repeated
60
+ workflow parses skip the eager compilation entirely.
61
+
62
+ Parameters
63
+ ----------
64
+ validity_path : str or pathlib.Path
65
+ Directory containing the ``validity.yaml`` file and all referenced
66
+ database files.
67
+
68
+ Returns
69
+ -------
70
+ dbetto.catalog.Catalog
71
+ Pre-compiled catalog with all entries eagerly resolved.
72
+ """
73
+ if isinstance(validity_path, str):
74
+ validity_path = Path(validity_path)
75
+
76
+ cache_dir = _cache_dir(validity_path)
77
+ cache_file = cache_dir / f"{_content_digest(validity_path)}.pkl"
78
+ if cache_file.is_file() and not cache_file.is_symlink():
79
+ try:
80
+ with cache_file.open("rb") as f:
81
+ cached = pickle.load(f)
82
+ # tampered/corrupt caches must never be handed back as a catalog
83
+ if isinstance(cached, Catalog):
84
+ return cached
85
+ except Exception: # unreadable pickle: rebuild below
86
+ log.debug("could not read catalog cache %s", cache_file, exc_info=True)
87
+
88
+ catalog = Catalog.read_from(validity_path / "validity.yaml")
89
+ entries = {}
90
+ textdb = TextDB(validity_path, lazy=False)
91
+ for system in catalog.entries:
92
+ entries[system] = []
93
+ for entry in catalog.entries[system]:
94
+ db = textdb.on(
95
+ datetime.fromtimestamp(entry.valid_from, tz=UTC), category=system
96
+ )
97
+ new_entry = Catalog.Entry(entry.valid_from, db)
98
+ entries[system].append(new_entry)
99
+ compiled = Catalog(entries)
100
+
101
+ try:
102
+ cache_dir.mkdir(parents=True, exist_ok=True)
103
+ for stale in cache_dir.glob("*.pkl"):
104
+ stale.unlink(missing_ok=True)
105
+ fd, tmp_name = tempfile.mkstemp(dir=cache_dir, suffix=".tmp")
106
+ try:
107
+ with os.fdopen(fd, "wb") as f:
108
+ pickle.dump(compiled, f, protocol=pickle.HIGHEST_PROTOCOL)
109
+ Path(tmp_name).replace(cache_file)
110
+ except BaseException:
111
+ Path(tmp_name).unlink(missing_ok=True)
112
+ raise
113
+ except Exception:
114
+ # caching is best-effort; a failed write must not break parsing
115
+ log.debug("could not write catalog cache %s", cache_file, exc_info=True)
116
+
117
+ return compiled
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.4.0a1",
3
- "distance": 0,
4
- "node": "g30429c60446a5d3e19023e462981054d0cda7d7d",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-16"
8
- }
@@ -1,44 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from datetime import UTC, datetime
4
- from pathlib import Path
5
-
6
- from dbetto import TextDB
7
- from dbetto.catalog import Catalog
8
-
9
-
10
- def pre_compile_catalog(validity_path: str | Path):
11
- """Pre-compile a dbetto validity catalog for fast repeated access.
12
-
13
- Reads the ``validity.yaml`` catalog from *validity_path* and, for each
14
- system and each entry in the catalog, eagerly loads the corresponding
15
- :class:`dbetto.TextDB` state (instead of loading it lazily on first
16
- access). The resulting :class:`dbetto.catalog.Catalog` can be serialised
17
- and reused across many Snakemake jobs without re-parsing YAML on every
18
- invocation.
19
-
20
- Parameters
21
- ----------
22
- validity_path : str or pathlib.Path
23
- Directory containing the ``validity.yaml`` file and all referenced
24
- database files.
25
-
26
- Returns
27
- -------
28
- dbetto.catalog.Catalog
29
- Pre-compiled catalog with all entries eagerly resolved.
30
- """
31
- if isinstance(validity_path, str):
32
- validity_path = Path(validity_path)
33
- catalog = Catalog.read_from(validity_path / "validity.yaml")
34
- entries = {}
35
- textdb = TextDB(validity_path, lazy=False)
36
- for system in catalog.entries:
37
- entries[system] = []
38
- for entry in catalog.entries[system]:
39
- db = textdb.on(
40
- datetime.fromtimestamp(entry.valid_from, tz=UTC), system=system
41
- )
42
- new_entry = Catalog.Entry(entry.valid_from, db)
43
- entries[system].append(new_entry)
44
- return Catalog(entries)