optmapper 0.1.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.
- optmapper/__init__.py +7 -0
- optmapper/_version.py +24 -0
- optmapper/cli.py +150 -0
- optmapper/config.py +342 -0
- optmapper/geometry.py +56 -0
- optmapper/log_utils.py +29 -0
- optmapper/macro.py +136 -0
- optmapper/schedulers.py +108 -0
- optmapper/sites/local.yaml +3 -0
- optmapper/sites/nersc.yaml +23 -0
- optmapper/sites/slurm.yaml +12 -0
- optmapper/templates/optmap.mac +30 -0
- optmapper/work.py +240 -0
- optmapper-0.1.0.dist-info/METADATA +58 -0
- optmapper-0.1.0.dist-info/RECORD +19 -0
- optmapper-0.1.0.dist-info/WHEEL +5 -0
- optmapper-0.1.0.dist-info/entry_points.txt +3 -0
- optmapper-0.1.0.dist-info/licenses/LICENSE +674 -0
- optmapper-0.1.0.dist-info/top_level.txt +1 -0
optmapper/__init__.py
ADDED
optmapper/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
optmapper/cli.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""User facing command line interface, schedules the production jobs and exits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config import Config, ConfigError
|
|
13
|
+
from .log_utils import setup_log
|
|
14
|
+
from .schedulers import Job, LocalScheduler, get_scheduler
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_launcher(cfg: Config) -> list[str]:
|
|
20
|
+
"""Command prefix to run the worker in the current software environment.
|
|
21
|
+
|
|
22
|
+
Defaults to ``pixi run --as-is`` with the manifest of the environment
|
|
23
|
+
``optmapper`` is running in, if any.
|
|
24
|
+
"""
|
|
25
|
+
if cfg.execution.launcher is not None:
|
|
26
|
+
return list(cfg.execution.launcher)
|
|
27
|
+
manifest = os.environ.get("PIXI_PROJECT_MANIFEST")
|
|
28
|
+
if manifest is None:
|
|
29
|
+
return []
|
|
30
|
+
launcher = ["pixi", "run", "--as-is", "--manifest-path", manifest]
|
|
31
|
+
env_name = os.environ.get("PIXI_ENVIRONMENT_NAME", "default")
|
|
32
|
+
if env_name != "default":
|
|
33
|
+
launcher += ["--environment", env_name]
|
|
34
|
+
return launcher
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def submit(cfg: Config, dry_run: bool = False) -> list[str]:
|
|
38
|
+
"""Submit the node jobs and, if needed, the final merge job. Return the job IDs."""
|
|
39
|
+
scheduler = get_scheduler(cfg.execution.scheduler, dry_run=dry_run)
|
|
40
|
+
work = [
|
|
41
|
+
*resolve_launcher(cfg),
|
|
42
|
+
"optmapper-work",
|
|
43
|
+
"--verbose",
|
|
44
|
+
]
|
|
45
|
+
config = ["--config", str(cfg.resolved_config_file)]
|
|
46
|
+
|
|
47
|
+
node_ids = []
|
|
48
|
+
for i in range(cfg.statistics.nodes):
|
|
49
|
+
job = Job(
|
|
50
|
+
name=f"{cfg.name}-node{i:04d}",
|
|
51
|
+
command=[*work, "node", *config, "--index", str(i)],
|
|
52
|
+
log_file=cfg.log_dir / f"{cfg.name}-node{i:04d}-%j.log",
|
|
53
|
+
options=cfg.execution.slurm.get("node", {}),
|
|
54
|
+
)
|
|
55
|
+
node_ids.append(scheduler.submit(job))
|
|
56
|
+
log.info("submitted job %s (%s)", node_ids[-1], job.name)
|
|
57
|
+
|
|
58
|
+
ids = list(node_ids)
|
|
59
|
+
if cfg.statistics.nodes > 1:
|
|
60
|
+
job = Job(
|
|
61
|
+
name=f"{cfg.name}-merge",
|
|
62
|
+
command=[*work, "merge", *config],
|
|
63
|
+
log_file=cfg.log_dir / f"{cfg.name}-merge-%j.log",
|
|
64
|
+
options=cfg.execution.slurm.get("merge", {}),
|
|
65
|
+
dependencies=node_ids,
|
|
66
|
+
)
|
|
67
|
+
ids.append(scheduler.submit(job))
|
|
68
|
+
log.info("submitted job %s (%s), after all node jobs", ids[-1], job.name)
|
|
69
|
+
|
|
70
|
+
if isinstance(scheduler, LocalScheduler) and scheduler.failed:
|
|
71
|
+
msg = f"failed jobs: {', '.join(scheduler.failed)}"
|
|
72
|
+
raise RuntimeError(msg)
|
|
73
|
+
return ids
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def optmapper_cli(argv: list[str] | None = None) -> None:
|
|
77
|
+
parser = argparse.ArgumentParser(
|
|
78
|
+
prog="optmapper",
|
|
79
|
+
description="%(prog)s: produce LEGEND optical maps. Prepares the geometry, "
|
|
80
|
+
"submits the production jobs according to the configuration and exits.",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument("config", help="production configuration file (YAML or JSON)")
|
|
83
|
+
parser.add_argument("--verbose", "-v", action="count", default=0, help="increase verbosity")
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--dry-run",
|
|
86
|
+
"-n",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="validate the configuration and show the jobs that would be submitted",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--force",
|
|
92
|
+
action="store_true",
|
|
93
|
+
help="allow reusing an output directory that already contains a production",
|
|
94
|
+
)
|
|
95
|
+
args = parser.parse_args(argv)
|
|
96
|
+
# report the submitted jobs by default
|
|
97
|
+
setup_log((logging.INFO, logging.DEBUG)[min(args.verbose, 1)])
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
cfg = Config.load(args.config)
|
|
101
|
+
_prepare(cfg, dry_run=args.dry_run, force=args.force)
|
|
102
|
+
submit(cfg, dry_run=args.dry_run)
|
|
103
|
+
except (ConfigError, RuntimeError) as e:
|
|
104
|
+
log.error("%s", e)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
if not args.dry_run:
|
|
108
|
+
log.info("optical map will be written to %s", cfg.final_map)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _prepare(cfg: Config, dry_run: bool, force: bool) -> None:
|
|
112
|
+
"""Validate the configuration, build the geometry and store the resolved config."""
|
|
113
|
+
from .macro import render_macro # noqa: PLC0415
|
|
114
|
+
|
|
115
|
+
if cfg.resolved_config_file.exists() and not force:
|
|
116
|
+
msg = (
|
|
117
|
+
f"{cfg.output_dir} already contains a production, choose another "
|
|
118
|
+
"output_dir or use --force"
|
|
119
|
+
)
|
|
120
|
+
raise ConfigError(msg)
|
|
121
|
+
|
|
122
|
+
# fail early on template or emission spectrum errors
|
|
123
|
+
render_macro(cfg, cfg.statistics.primaries_per_process)
|
|
124
|
+
|
|
125
|
+
if shutil.which("remage") is None:
|
|
126
|
+
log.warning("remage not found in the current environment")
|
|
127
|
+
|
|
128
|
+
if cfg.geometry.gdml is not None and not Path(cfg.geometry.gdml).is_file():
|
|
129
|
+
msg = f"GDML file {cfg.geometry.gdml} not found"
|
|
130
|
+
raise ConfigError(msg)
|
|
131
|
+
|
|
132
|
+
s = cfg.statistics
|
|
133
|
+
log.info(
|
|
134
|
+
"production %s: %d node(s) x %d run(s) x %s processes x %.3g primaries",
|
|
135
|
+
cfg.name,
|
|
136
|
+
s.nodes,
|
|
137
|
+
s.runs_per_node,
|
|
138
|
+
s.processes_per_node or "all CPU",
|
|
139
|
+
s.primaries_per_process,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if dry_run:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
if cfg.geometry.executable is not None:
|
|
147
|
+
from .geometry import build_gdml # noqa: PLC0415
|
|
148
|
+
|
|
149
|
+
build_gdml(cfg)
|
|
150
|
+
cfg.dump(cfg.resolved_config_file)
|
optmapper/config.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Production configuration: parsing, validation and site presets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import dataclasses
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import dbetto
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(ValueError):
|
|
19
|
+
"""Invalid production configuration."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_keys(block: Mapping, allowed: set[str], where: str) -> None:
|
|
23
|
+
unknown = set(block) - allowed
|
|
24
|
+
if unknown:
|
|
25
|
+
msg = f"unknown key(s) {sorted(unknown)} in '{where}', allowed: {sorted(allowed)}"
|
|
26
|
+
raise ConfigError(msg)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_path(path: str | os.PathLike | None, base: Path) -> str | None:
|
|
30
|
+
"""Make a path from the config absolute, relative paths are relative to `base`."""
|
|
31
|
+
if path is None:
|
|
32
|
+
return None
|
|
33
|
+
p = Path(os.path.expandvars(os.fspath(path))).expanduser()
|
|
34
|
+
return str(p if p.is_absolute() else (base / p).resolve())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def deep_merge(base: Mapping, override: Mapping) -> dict:
|
|
38
|
+
"""Recursively merge `override` into a copy of `base`."""
|
|
39
|
+
out = copy.deepcopy(dict(base))
|
|
40
|
+
for k, v in override.items():
|
|
41
|
+
if isinstance(v, Mapping) and isinstance(out.get(k), Mapping):
|
|
42
|
+
out[k] = deep_merge(out[k], v)
|
|
43
|
+
else:
|
|
44
|
+
out[k] = copy.deepcopy(v)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_site(site: str | Mapping) -> dict:
|
|
49
|
+
"""Load an execution site preset.
|
|
50
|
+
|
|
51
|
+
`site` is either the name of a preset shipped with this package (e.g. ``nersc``,
|
|
52
|
+
``slurm``, ``local``), the path to a YAML/JSON preset file or an inline mapping.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(site, Mapping):
|
|
55
|
+
return dict(site)
|
|
56
|
+
preset = resources.files("optmapper") / "sites" / f"{site}.yaml"
|
|
57
|
+
if preset.is_file():
|
|
58
|
+
return dbetto.utils.load_dict(str(preset))
|
|
59
|
+
if Path(site).is_file():
|
|
60
|
+
return dbetto.utils.load_dict(site)
|
|
61
|
+
available = sorted(
|
|
62
|
+
Path(p.name).stem for p in (resources.files("optmapper") / "sites").iterdir()
|
|
63
|
+
)
|
|
64
|
+
msg = f"site preset '{site}' not found, available presets: {available} (or a file path)"
|
|
65
|
+
raise ConfigError(msg)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class GeometryConfig:
|
|
70
|
+
"""Either a GDML file or a geometry generator invocation."""
|
|
71
|
+
|
|
72
|
+
gdml: str | None = None
|
|
73
|
+
executable: str | None = None
|
|
74
|
+
config: str | dict | None = None
|
|
75
|
+
optics_plugin: str | None = None
|
|
76
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
77
|
+
"""Environment of the generator, e.g. ``LEGEND_METADATA``."""
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, d: Mapping, base: Path) -> GeometryConfig:
|
|
81
|
+
_check_keys(d, {f.name for f in dataclasses.fields(cls)}, "geometry")
|
|
82
|
+
g = cls(**d)
|
|
83
|
+
if (g.gdml is None) == (g.executable is None):
|
|
84
|
+
msg = "geometry: specify exactly one of 'gdml' or 'executable'"
|
|
85
|
+
raise ConfigError(msg)
|
|
86
|
+
if g.gdml is not None and (g.config is not None or g.optics_plugin is not None or g.env):
|
|
87
|
+
msg = "geometry: 'config', 'optics_plugin' and 'env' require 'executable'"
|
|
88
|
+
raise ConfigError(msg)
|
|
89
|
+
g.gdml = _resolve_path(g.gdml, base)
|
|
90
|
+
if not isinstance(g.config, Mapping):
|
|
91
|
+
g.config = _resolve_path(g.config, base)
|
|
92
|
+
else:
|
|
93
|
+
g.config = dict(g.config)
|
|
94
|
+
g.optics_plugin = _resolve_path(g.optics_plugin, base)
|
|
95
|
+
g.env = {k: os.path.expandvars(str(v)) for k, v in g.env.items()}
|
|
96
|
+
return g
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class EmissionConfig:
|
|
101
|
+
"""Energy spectrum of the generated optical photons.
|
|
102
|
+
|
|
103
|
+
Either `spectrum`, the dotted path of a function with signature
|
|
104
|
+
``f(filename: str, output_macro: bool)`` like
|
|
105
|
+
:func:`pygeomoptics.lar.g4gps_lar_emissions_spectrum`, or a `gaussian` with a
|
|
106
|
+
`mean` (energy or wavelength) and a `sigma` (energy).
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
spectrum: str | None = None
|
|
110
|
+
gaussian: dict | None = None
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_dict(cls, d: Mapping) -> EmissionConfig:
|
|
114
|
+
_check_keys(d, {"spectrum", "gaussian"}, "emission")
|
|
115
|
+
e = cls(**d)
|
|
116
|
+
if (e.spectrum is None) == (e.gaussian is None):
|
|
117
|
+
msg = "emission: specify exactly one of 'spectrum' or 'gaussian'"
|
|
118
|
+
raise ConfigError(msg)
|
|
119
|
+
if e.gaussian is not None:
|
|
120
|
+
e.gaussian = dict(e.gaussian)
|
|
121
|
+
_check_keys(e.gaussian, {"mean", "sigma"}, "emission.gaussian")
|
|
122
|
+
if set(e.gaussian) != {"mean", "sigma"}:
|
|
123
|
+
msg = "emission.gaussian: both 'mean' and 'sigma' are required"
|
|
124
|
+
raise ConfigError(msg)
|
|
125
|
+
return e
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class StatisticsConfig:
|
|
130
|
+
nodes: int = 1
|
|
131
|
+
runs_per_node: int = 1
|
|
132
|
+
primaries_per_process: int = 1_000_000
|
|
133
|
+
processes_per_node: int | None = None
|
|
134
|
+
"""Number of remage processes per run, defaults to all CPUs available to the job."""
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_dict(cls, d: Mapping) -> StatisticsConfig:
|
|
138
|
+
_check_keys(d, {f.name for f in dataclasses.fields(cls)}, "statistics")
|
|
139
|
+
s = cls(**d)
|
|
140
|
+
for f in dataclasses.fields(s):
|
|
141
|
+
v = getattr(s, f.name)
|
|
142
|
+
if v is not None and (not isinstance(v, int) or v < 1):
|
|
143
|
+
msg = f"statistics.{f.name} must be a positive integer, got {v!r}"
|
|
144
|
+
raise ConfigError(msg)
|
|
145
|
+
return s
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class OptmapConfig:
|
|
150
|
+
"""Optical map binning and the primary vertex confinement derived from it."""
|
|
151
|
+
|
|
152
|
+
range_in_m: list[list[float]]
|
|
153
|
+
bins: list[int]
|
|
154
|
+
volume: str | list[str] = "liquid_argon"
|
|
155
|
+
shape: str = "cylinder"
|
|
156
|
+
inner_radius_in_m: float = 0
|
|
157
|
+
detectors: list | None = None
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def from_dict(cls, d: Mapping) -> OptmapConfig:
|
|
161
|
+
_check_keys(d, {f.name for f in dataclasses.fields(cls)}, "optmap")
|
|
162
|
+
for k in ("range_in_m", "bins"):
|
|
163
|
+
if k not in d:
|
|
164
|
+
msg = f"optmap.{k} is required"
|
|
165
|
+
raise ConfigError(msg)
|
|
166
|
+
o = cls(**d)
|
|
167
|
+
o.range_in_m = [[float(lo), float(hi)] for lo, hi in o.range_in_m]
|
|
168
|
+
o.bins = [int(b) for b in o.bins]
|
|
169
|
+
if len(o.range_in_m) != 3 or len(o.bins) != 3:
|
|
170
|
+
msg = "optmap.range_in_m and optmap.bins must have three entries (x, y, z)"
|
|
171
|
+
raise ConfigError(msg)
|
|
172
|
+
if any(hi <= lo for lo, hi in o.range_in_m) or any(b < 1 for b in o.bins):
|
|
173
|
+
msg = "optmap: empty range or invalid bin count"
|
|
174
|
+
raise ConfigError(msg)
|
|
175
|
+
if o.shape not in ("cylinder", "box", "none"):
|
|
176
|
+
msg = f"optmap.shape must be one of cylinder, box, none, got {o.shape!r}"
|
|
177
|
+
raise ConfigError(msg)
|
|
178
|
+
if o.shape == "cylinder":
|
|
179
|
+
(x0, x1), (y0, y1), _ = o.range_in_m
|
|
180
|
+
if abs((x1 - x0) - (y1 - y0)) > 1e-9:
|
|
181
|
+
msg = "optmap.shape cylinder requires equal x and y ranges"
|
|
182
|
+
raise ConfigError(msg)
|
|
183
|
+
if not 0 <= o.inner_radius_in_m < (x1 - x0) / 2:
|
|
184
|
+
msg = "optmap.inner_radius_in_m must be >= 0 and smaller than the outer radius"
|
|
185
|
+
raise ConfigError(msg)
|
|
186
|
+
elif o.inner_radius_in_m != 0:
|
|
187
|
+
msg = "optmap.inner_radius_in_m is only valid for shape cylinder"
|
|
188
|
+
raise ConfigError(msg)
|
|
189
|
+
return o
|
|
190
|
+
|
|
191
|
+
def reboost_settings(self) -> dict:
|
|
192
|
+
"""Map settings in the format expected by :mod:`reboost.optmap`."""
|
|
193
|
+
return {"range_in_m": self.range_in_m, "bins": self.bins}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class ProcessingConfig:
|
|
198
|
+
stp_files_per_map: int = 32
|
|
199
|
+
parallel_maps: int = 8
|
|
200
|
+
procs_per_map: int = 16
|
|
201
|
+
merge_procs: int = 8
|
|
202
|
+
bufsize: int = 50_000
|
|
203
|
+
keep_stp: bool = False
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def from_dict(cls, d: Mapping) -> ProcessingConfig:
|
|
207
|
+
_check_keys(d, {f.name for f in dataclasses.fields(cls)}, "processing")
|
|
208
|
+
return cls(**d)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class AdvancedConfig:
|
|
213
|
+
template: str | None = None
|
|
214
|
+
"""Custom macro template, see the default ``templates/optmap.mac``."""
|
|
215
|
+
pre_init_commands: list[str] = field(default_factory=list)
|
|
216
|
+
"""Macro commands inserted before ``/run/initialize``."""
|
|
217
|
+
commands: list[str] = field(default_factory=list)
|
|
218
|
+
"""Macro commands inserted before ``/run/beamOn``."""
|
|
219
|
+
remage_args: list[str] = field(default_factory=lambda: ["--ignore-warnings"])
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def from_dict(cls, d: Mapping, base: Path) -> AdvancedConfig:
|
|
223
|
+
_check_keys(d, {f.name for f in dataclasses.fields(cls)}, "advanced")
|
|
224
|
+
a = cls(**d)
|
|
225
|
+
a.template = _resolve_path(a.template, base)
|
|
226
|
+
return a
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class ExecutionConfig:
|
|
231
|
+
"""Where and how the jobs run, the result of merging a site preset with user overrides."""
|
|
232
|
+
|
|
233
|
+
scheduler: str = "local"
|
|
234
|
+
scratch_dir: str | None = None
|
|
235
|
+
"""Fast temporary storage, environment variables are expanded on the worker."""
|
|
236
|
+
launcher: list[str] | None = None
|
|
237
|
+
"""Command prefix to run the worker in the software environment (e.g. ``pixi run``)."""
|
|
238
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
239
|
+
slurm: dict = field(default_factory=dict)
|
|
240
|
+
"""``sbatch`` options for the ``node`` and ``merge`` jobs."""
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def from_dict(cls, d: Mapping) -> ExecutionConfig:
|
|
244
|
+
d = dict(d)
|
|
245
|
+
site = load_site(d.pop("site", "local"))
|
|
246
|
+
merged = deep_merge(site, d)
|
|
247
|
+
_check_keys(merged, {f.name for f in dataclasses.fields(cls)}, "execution")
|
|
248
|
+
e = cls(**merged)
|
|
249
|
+
if e.scheduler not in ("slurm", "local"):
|
|
250
|
+
msg = f"execution.scheduler must be slurm or local, got {e.scheduler!r}"
|
|
251
|
+
raise ConfigError(msg)
|
|
252
|
+
_check_keys(e.slurm, {"node", "merge"}, "execution.slurm")
|
|
253
|
+
e.env = {k: str(v) for k, v in e.env.items()}
|
|
254
|
+
return e
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class Config:
|
|
259
|
+
name: str
|
|
260
|
+
output_dir: str
|
|
261
|
+
geometry: GeometryConfig
|
|
262
|
+
emission: EmissionConfig
|
|
263
|
+
optmap: OptmapConfig
|
|
264
|
+
statistics: StatisticsConfig = field(default_factory=StatisticsConfig)
|
|
265
|
+
processing: ProcessingConfig = field(default_factory=ProcessingConfig)
|
|
266
|
+
advanced: AdvancedConfig = field(default_factory=AdvancedConfig)
|
|
267
|
+
execution: ExecutionConfig = field(default_factory=ExecutionConfig)
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def from_dict(cls, d: Mapping, base: Path | None = None) -> Config:
|
|
271
|
+
"""Build a config from a mapping, relative paths are resolved relative to `base`.
|
|
272
|
+
|
|
273
|
+
``$_`` in any string value is replaced with `base`, as in dbetto.
|
|
274
|
+
"""
|
|
275
|
+
base = Path.cwd() if base is None else Path(base).resolve()
|
|
276
|
+
d = copy.deepcopy(dict(d))
|
|
277
|
+
dbetto.Props.subst_vars(d, var_values={"_": str(base)}, ignore_missing=True)
|
|
278
|
+
_check_keys(d, {f.name for f in dataclasses.fields(cls)}, "top level")
|
|
279
|
+
for k in ("name", "output_dir", "geometry", "emission", "optmap"):
|
|
280
|
+
if k not in d:
|
|
281
|
+
msg = f"missing required key '{k}'"
|
|
282
|
+
raise ConfigError(msg)
|
|
283
|
+
if not str(d["name"]) or "/" in str(d["name"]):
|
|
284
|
+
msg = f"invalid production name {d['name']!r}"
|
|
285
|
+
raise ConfigError(msg)
|
|
286
|
+
try:
|
|
287
|
+
return cls(
|
|
288
|
+
name=str(d["name"]),
|
|
289
|
+
output_dir=str(_resolve_path(d["output_dir"], base)),
|
|
290
|
+
geometry=GeometryConfig.from_dict(d["geometry"], base),
|
|
291
|
+
emission=EmissionConfig.from_dict(d["emission"]),
|
|
292
|
+
optmap=OptmapConfig.from_dict(d["optmap"]),
|
|
293
|
+
statistics=StatisticsConfig.from_dict(d.get("statistics", {})),
|
|
294
|
+
processing=ProcessingConfig.from_dict(d.get("processing", {})),
|
|
295
|
+
advanced=AdvancedConfig.from_dict(d.get("advanced", {}), base),
|
|
296
|
+
execution=ExecutionConfig.from_dict(d.get("execution", {})),
|
|
297
|
+
)
|
|
298
|
+
except TypeError as e:
|
|
299
|
+
# e.g. a mapping where a list is expected
|
|
300
|
+
raise ConfigError(str(e)) from e
|
|
301
|
+
|
|
302
|
+
@classmethod
|
|
303
|
+
def load(cls, path: str | os.PathLike) -> Config:
|
|
304
|
+
"""Load a user configuration file, relative paths are relative to its directory."""
|
|
305
|
+
return cls.from_dict(dbetto.utils.load_dict(str(path)), Path(path).resolve().parent)
|
|
306
|
+
|
|
307
|
+
def to_dict(self) -> dict[str, Any]:
|
|
308
|
+
d = dataclasses.asdict(self)
|
|
309
|
+
# the site preset has already been merged in, do not apply any on reload
|
|
310
|
+
d["execution"]["site"] = {}
|
|
311
|
+
return d
|
|
312
|
+
|
|
313
|
+
def dump(self, path: str | os.PathLike) -> None:
|
|
314
|
+
"""Write the fully resolved configuration, to be read back by the workers."""
|
|
315
|
+
with Path(path).open("w") as f:
|
|
316
|
+
json.dump(self.to_dict(), f, indent=2)
|
|
317
|
+
|
|
318
|
+
# file layout of a production
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def resolved_config_file(self) -> Path:
|
|
322
|
+
return Path(self.output_dir) / "optmapper-config.json"
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def gdml_file(self) -> Path:
|
|
326
|
+
if self.geometry.gdml is not None:
|
|
327
|
+
return Path(self.geometry.gdml)
|
|
328
|
+
return Path(self.output_dir) / "geom.gdml"
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def log_dir(self) -> Path:
|
|
332
|
+
return Path(self.output_dir) / "logs"
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def final_map(self) -> Path:
|
|
336
|
+
return Path(self.output_dir) / f"{self.name}.lh5"
|
|
337
|
+
|
|
338
|
+
def node_map(self, index: int) -> Path:
|
|
339
|
+
"""Output map of a single node, the final map if there is only one node."""
|
|
340
|
+
if self.statistics.nodes == 1:
|
|
341
|
+
return self.final_map
|
|
342
|
+
return Path(self.output_dir) / "nodes" / f"{self.name}-node{index:04d}.lh5"
|
optmapper/geometry.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Build the GDML geometry with a LEGEND geometry generator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import dbetto
|
|
13
|
+
|
|
14
|
+
from .config import Config, ConfigError
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_gdml(cfg: Config) -> None:
|
|
20
|
+
"""Run the geometry generator (e.g. ``legend-pygeom-l200``) to write ``cfg.gdml_file``.
|
|
21
|
+
|
|
22
|
+
The geometry configuration is stored next to the GDML file, for the record.
|
|
23
|
+
"""
|
|
24
|
+
geom = cfg.geometry
|
|
25
|
+
assert geom.executable is not None
|
|
26
|
+
if shutil.which(geom.executable) is None:
|
|
27
|
+
msg = f"geometry generator '{geom.executable}' not found, is it installed?"
|
|
28
|
+
raise ConfigError(msg)
|
|
29
|
+
|
|
30
|
+
cmd = [geom.executable, "--verbose"]
|
|
31
|
+
|
|
32
|
+
if geom.config is not None:
|
|
33
|
+
if isinstance(geom.config, dict):
|
|
34
|
+
gconfig = geom.config
|
|
35
|
+
else:
|
|
36
|
+
gconfig = dbetto.utils.load_dict(geom.config)
|
|
37
|
+
# expand $_ to the directory holding the file, as dbetto does
|
|
38
|
+
dbetto.Props.subst_vars(gconfig, var_values={"_": str(Path(geom.config).parent)})
|
|
39
|
+
config_file = Path(cfg.output_dir) / "geom-config.json"
|
|
40
|
+
dbetto.utils.write_dict(gconfig, str(config_file))
|
|
41
|
+
cmd += ["--config", str(config_file)]
|
|
42
|
+
|
|
43
|
+
if geom.optics_plugin is not None:
|
|
44
|
+
cmd += ["--pygeom-optics-plugin", geom.optics_plugin]
|
|
45
|
+
|
|
46
|
+
cmd += ["--", str(cfg.gdml_file)]
|
|
47
|
+
|
|
48
|
+
env = os.environ | geom.env
|
|
49
|
+
|
|
50
|
+
log_file = cfg.log_dir / "geometry.log"
|
|
51
|
+
log.info("building geometry: %s (log in %s)", shlex.join(cmd), log_file)
|
|
52
|
+
with log_file.open("w") as f:
|
|
53
|
+
ret = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT, env=env, check=False)
|
|
54
|
+
if ret.returncode != 0:
|
|
55
|
+
msg = f"geometry generation failed, see {log_file}"
|
|
56
|
+
raise RuntimeError(msg)
|
optmapper/log_utils.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import colorlog
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def setup_log(level: int | None = None, multiproc: bool = False) -> None:
|
|
9
|
+
"""Setup a colored logger for this package.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
level
|
|
14
|
+
initial log level, or ``None`` to use the default.
|
|
15
|
+
multiproc
|
|
16
|
+
set to ``True`` to include process ID in log output (i.e. for multiprocessing setups)
|
|
17
|
+
"""
|
|
18
|
+
fmt = "%(log_color)s%(name)s [%(levelname)s]"
|
|
19
|
+
if multiproc:
|
|
20
|
+
fmt += " (pid=%(process)s)"
|
|
21
|
+
fmt += " %(message)s"
|
|
22
|
+
|
|
23
|
+
handler = colorlog.StreamHandler()
|
|
24
|
+
handler.setFormatter(colorlog.ColoredFormatter(fmt))
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("optmapper")
|
|
27
|
+
logger.addHandler(handler)
|
|
28
|
+
if level is not None:
|
|
29
|
+
logger.setLevel(level)
|