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 ADDED
@@ -0,0 +1,7 @@
1
+ """Production of LEGEND optical maps on batch systems."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._version import version as __version__
6
+
7
+ __all__ = ["__version__"]
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)