lheutils 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,619 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI tool to validate LHE files and check momentum conservation.
4
+
5
+ This tool validates that LHE files can be loaded properly and checks
6
+ momentum conservation for each event up to a specified precision.
7
+ """
8
+
9
+ # APN TODO this should also check mother daughter momentum conservations
10
+
11
+ import argparse
12
+ import math
13
+ import sys
14
+ import warnings
15
+ from collections.abc import Iterable
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any, Optional, TextIO, Union
19
+
20
+ from typing_extensions import Self
21
+
22
+ import pylhe
23
+
24
+ from lheutils.cli.util import create_base_parser
25
+
26
+
27
+ def positive_float(value: str) -> float:
28
+ """Custom argparse type for positive floats."""
29
+ try:
30
+ fvalue = float(value)
31
+ except ValueError:
32
+ err = f"Invalid float value: '{value}'"
33
+ raise argparse.ArgumentTypeError(err) from None
34
+
35
+ if fvalue <= 0:
36
+ err = f"Value must be positive, got: {fvalue}"
37
+ raise argparse.ArgumentTypeError(err)
38
+
39
+ return fvalue
40
+
41
+
42
+ @dataclass
43
+ class LHECheckArgs:
44
+ positive_mass: bool
45
+ positive_mass_abs: float
46
+ onshell: bool
47
+ onshell_rel: float
48
+ onshell_abs: float
49
+ total_momentum: bool
50
+ total_momentum_rel: float
51
+ total_momentum_abs: float
52
+
53
+
54
+ @dataclass
55
+ class LHECheckAccumulatedSummary:
56
+ total_files_checked: int
57
+ total_events_with_violations: int
58
+ total_positive_mass_violations: int
59
+ total_onshell_violations: int
60
+ total_total_momentum_violations: int
61
+
62
+ @property
63
+ def total_violations(self) -> int:
64
+ return (
65
+ self.total_positive_mass_violations
66
+ + self.total_onshell_violations
67
+ + self.total_total_momentum_violations
68
+ )
69
+
70
+ def __add__(
71
+ self, other: "LHECheckAccumulatedSummary"
72
+ ) -> "LHECheckAccumulatedSummary":
73
+ return LHECheckAccumulatedSummary(
74
+ total_files_checked=self.total_files_checked + other.total_files_checked,
75
+ total_events_with_violations=self.total_events_with_violations
76
+ + other.total_events_with_violations,
77
+ total_positive_mass_violations=self.total_positive_mass_violations
78
+ + other.total_positive_mass_violations,
79
+ total_onshell_violations=self.total_onshell_violations
80
+ + other.total_onshell_violations,
81
+ total_total_momentum_violations=self.total_total_momentum_violations
82
+ + other.total_total_momentum_violations,
83
+ )
84
+
85
+ def __iadd__(self, other: "LHECheckAccumulatedSummary") -> Self:
86
+ self.total_files_checked += other.total_files_checked
87
+ self.total_events_with_violations += other.total_events_with_violations
88
+ self.total_positive_mass_violations += other.total_positive_mass_violations
89
+ self.total_onshell_violations += other.total_onshell_violations
90
+ self.total_total_momentum_violations += other.total_total_momentum_violations
91
+ return self
92
+
93
+ def print(self, *args: Any, **kwargs: Any) -> None:
94
+ strings = []
95
+ strings.append(f"Total files checked: {self.total_files_checked}")
96
+ strings.append(
97
+ f"Total events with violations: {self.total_events_with_violations}"
98
+ )
99
+ strings.append(f"Total violations: {self.total_violations}")
100
+ strings.append(
101
+ f" Positive mass violations: {self.total_positive_mass_violations}"
102
+ )
103
+ strings.append(f" On-shell mass violations: {self.total_onshell_violations}")
104
+ strings.append(
105
+ f" Total momentum violations: {self.total_total_momentum_violations}"
106
+ )
107
+
108
+ print("\n".join(strings), *args, **kwargs)
109
+
110
+
111
+ @dataclass
112
+ class LHEMomentum:
113
+ px: float
114
+ py: float
115
+ pz: float
116
+ e: float
117
+
118
+
119
+ @dataclass
120
+ class LHECheckTotalMomentaViolations:
121
+ incoming: list[LHEMomentum]
122
+ outgoing: list[LHEMomentum]
123
+
124
+ @property
125
+ def total_incoming(self) -> LHEMomentum:
126
+ total_px = sum(p.px for p in self.incoming)
127
+ total_py = sum(p.py for p in self.incoming)
128
+ total_pz = sum(p.pz for p in self.incoming)
129
+ total_e = sum(p.e for p in self.incoming)
130
+ return LHEMomentum(px=total_px, py=total_py, pz=total_pz, e=total_e)
131
+
132
+ @property
133
+ def total_outgoing(self) -> LHEMomentum:
134
+ total_px = sum(p.px for p in self.outgoing)
135
+ total_py = sum(p.py for p in self.outgoing)
136
+ total_pz = sum(p.pz for p in self.outgoing)
137
+ total_e = sum(p.e for p in self.outgoing)
138
+ return LHEMomentum(px=total_px, py=total_py, pz=total_pz, e=total_e)
139
+
140
+ @property
141
+ def differences(self) -> LHEMomentum:
142
+ total_in = self.total_incoming
143
+ total_out = self.total_outgoing
144
+ return LHEMomentum(
145
+ px=abs(total_in.px - total_out.px),
146
+ py=abs(total_in.py - total_out.py),
147
+ pz=abs(total_in.pz - total_out.pz),
148
+ e=abs(total_in.e - total_out.e),
149
+ )
150
+
151
+ @property
152
+ def rel_differences(self) -> LHEMomentum:
153
+ diff = self.differences
154
+
155
+ refpx = max(
156
+ [abs(p.px) for p in self.incoming + self.outgoing] + [1e-12]
157
+ ) # Prevent division by zero
158
+ refpy = max([abs(p.py) for p in self.incoming + self.outgoing] + [1e-12])
159
+ refpz = max([abs(p.pz) for p in self.incoming + self.outgoing] + [1e-12])
160
+ refe = max([abs(p.e) for p in self.incoming + self.outgoing] + [1e-12])
161
+
162
+ return LHEMomentum(
163
+ px=diff.px / refpx, # Prevent division by zero
164
+ py=diff.py / refpy,
165
+ pz=diff.pz / refpz,
166
+ e=diff.e / refe,
167
+ )
168
+
169
+ def is_violation(
170
+ self, absolute_threshold: float, relative_threshold: float
171
+ ) -> bool:
172
+ diffs = self.differences
173
+ rel_diffs = self.rel_differences
174
+ return (
175
+ not (diffs.px < absolute_threshold or rel_diffs.px < relative_threshold)
176
+ or not (diffs.py < absolute_threshold or rel_diffs.py < relative_threshold)
177
+ or not (diffs.pz < absolute_threshold or rel_diffs.pz < relative_threshold)
178
+ or not (diffs.e < absolute_threshold or rel_diffs.e < relative_threshold)
179
+ )
180
+
181
+ def print(self, *args: Any, **kwargs: Any) -> LHECheckAccumulatedSummary:
182
+ incoming = self.total_incoming
183
+ outgoing = self.total_outgoing
184
+ diffs = self.differences
185
+ rel_diffs = self.rel_differences
186
+
187
+ lines = []
188
+ lines.append(f" {'Metric':<12} {'px':<12} {'py':<12} {'pz':<12} {'E':<12}")
189
+ lines.append(
190
+ f" {'-' * 12:<12} {'-' * 12:<12} {'-' * 12:<12} {'-' * 12:<12} {'-' * 12:<12}"
191
+ )
192
+
193
+ metrics = [
194
+ ("Incoming", incoming.px, incoming.py, incoming.pz, incoming.e),
195
+ ("Outgoing", outgoing.px, outgoing.py, outgoing.pz, outgoing.e),
196
+ ("Abs Diff", diffs.px, diffs.py, diffs.pz, diffs.e),
197
+ ("Rel Diff", rel_diffs.px, rel_diffs.py, rel_diffs.pz, rel_diffs.e),
198
+ ]
199
+
200
+ for metric, px_val, py_val, pz_val, e_val in metrics:
201
+ lines.append(
202
+ f" {metric:<12} {px_val:<12.4e} {py_val:<12.4e} {pz_val:<12.4e} {e_val:<12.4e}"
203
+ )
204
+ print("\n".join(lines), *args, **kwargs)
205
+ return LHECheckAccumulatedSummary(
206
+ total_files_checked=0,
207
+ total_events_with_violations=0,
208
+ total_positive_mass_violations=0,
209
+ total_onshell_violations=0,
210
+ total_total_momentum_violations=1,
211
+ )
212
+
213
+
214
+ @dataclass
215
+ class LHECheckOnShellViolation:
216
+ px: float
217
+ py: float
218
+ pz: float
219
+ e: float
220
+ m: float
221
+
222
+ @property
223
+ def p(self) -> float:
224
+ # TODO check fail on negative mass?!
225
+ return math.sqrt(abs(self.e**2 - (self.px**2 + self.py**2 + self.pz**2)))
226
+
227
+ @property
228
+ def difference(self) -> float:
229
+ return abs(self.p - self.m)
230
+
231
+ @property
232
+ def rel_difference(self) -> float:
233
+ return self.difference / max(
234
+ abs(self.m), abs(self.p), 1e-12
235
+ ) # Prevent division by zero
236
+
237
+ def is_violation(
238
+ self, absolute_threshold: float, relative_threshold: float
239
+ ) -> bool:
240
+ return not (
241
+ self.difference < absolute_threshold
242
+ or self.rel_difference < relative_threshold
243
+ )
244
+
245
+ def print(self, *args: Any, **kwargs: Any) -> LHECheckAccumulatedSummary:
246
+ lines = []
247
+ lines.append("✗ On-shell mass violation:")
248
+ lines.append(f" px: {self.px:>12.4e}")
249
+ lines.append(f" py: {self.py:>12.4e}")
250
+ lines.append(f" pz: {self.pz:>12.4e}")
251
+ lines.append(f" e: {self.e:>12.4e}")
252
+ lines.append(f" |p|: {self.p:>12.4e}")
253
+ lines.append(f" m: {self.m:>12.4e}")
254
+ lines.append(
255
+ f" ||p| - |m||: {self.difference:.4e} (rel: {self.rel_difference:.4e})"
256
+ )
257
+ print("\n".join(lines), *args, **kwargs)
258
+ return LHECheckAccumulatedSummary(
259
+ total_files_checked=0,
260
+ total_events_with_violations=0,
261
+ total_positive_mass_violations=0,
262
+ total_onshell_violations=1,
263
+ total_total_momentum_violations=0,
264
+ )
265
+
266
+
267
+ @dataclass
268
+ class LHECheckPositiveMassViolation:
269
+ px: float
270
+ py: float
271
+ pz: float
272
+ e: float
273
+
274
+ @property
275
+ def p2(self) -> float:
276
+ return self.px**2 + self.py**2 + self.pz**2
277
+
278
+ @property
279
+ def e2(self) -> float:
280
+ return self.e**2
281
+
282
+ @property
283
+ def m2(self) -> float:
284
+ return self.e2 - self.p2
285
+
286
+ def is_violation(self, absolute_threshold: float) -> bool:
287
+ return self.p2 - self.e2 > absolute_threshold**2
288
+
289
+ def print(self, *args: Any, **kwargs: Any) -> LHECheckAccumulatedSummary:
290
+ lines = []
291
+ lines.append("✗ Positive mass violation:")
292
+ lines.append(f" px: {self.px:>12.4e}")
293
+ lines.append(f" py: {self.py:>12.4e}")
294
+ lines.append(f" pz: {self.pz:>12.4e}")
295
+ lines.append(f" e: {self.e:>12.4e}")
296
+ lines.append(f" p²: {self.p2:>12.4e}")
297
+ lines.append(f" e²: {self.e2:>12.4e}")
298
+ lines.append(f" m²: {self.m2:>12.4e} (negative - unphysical)")
299
+ print("\n".join(lines), *args, **kwargs)
300
+ return LHECheckAccumulatedSummary(
301
+ total_files_checked=0,
302
+ total_events_with_violations=0,
303
+ total_positive_mass_violations=1,
304
+ total_onshell_violations=0,
305
+ total_total_momentum_violations=0,
306
+ )
307
+
308
+
309
+ @dataclass
310
+ class LHECheckParticleViolation:
311
+ particle_index: int
312
+ particle_pdgid: int
313
+ on_shell_violations: Optional[LHECheckOnShellViolation]
314
+ positive_mass_violation: Optional[LHECheckPositiveMassViolation]
315
+
316
+ @property
317
+ def total_violations(self) -> int:
318
+ return sum(
319
+ v is not None
320
+ for v in [self.on_shell_violations, self.positive_mass_violation]
321
+ )
322
+
323
+ def print(self, *args: Any, **kwargs: Any) -> LHECheckAccumulatedSummary:
324
+ ret = LHECheckAccumulatedSummary(
325
+ total_files_checked=0,
326
+ total_events_with_violations=0,
327
+ total_positive_mass_violations=0,
328
+ total_onshell_violations=0,
329
+ total_total_momentum_violations=0,
330
+ )
331
+ print(
332
+ f"✗ Particle {self.particle_index} (id={self.particle_pdgid}) violations:",
333
+ *args,
334
+ **kwargs,
335
+ )
336
+ if self.on_shell_violations is not None:
337
+ ret += self.on_shell_violations.print(*args, **kwargs)
338
+ if self.positive_mass_violation is not None:
339
+ ret += self.positive_mass_violation.print(*args, **kwargs)
340
+ return ret
341
+
342
+
343
+ @dataclass
344
+ class LHECheckEventViolation:
345
+ event_index: int
346
+ particle_violations: list[LHECheckParticleViolation]
347
+ total_momentum_violations: Optional[LHECheckTotalMomentaViolations]
348
+
349
+ @property
350
+ def total_violations(self) -> int:
351
+ count = sum(p.total_violations for p in self.particle_violations)
352
+ if self.total_momentum_violations is not None:
353
+ count += 1
354
+ return count
355
+
356
+ def print(self, *args: object, **kwargs: object) -> LHECheckAccumulatedSummary:
357
+ ret = LHECheckAccumulatedSummary(
358
+ total_files_checked=0,
359
+ total_events_with_violations=0,
360
+ total_positive_mass_violations=0,
361
+ total_onshell_violations=0,
362
+ total_total_momentum_violations=0,
363
+ )
364
+ print(f"✗ Event {self.event_index} violations:")
365
+ for pviolation in self.particle_violations:
366
+ ret += pviolation.print(*args, **kwargs)
367
+ if self.total_momentum_violations is not None:
368
+ ret += self.total_momentum_violations.print(*args, **kwargs)
369
+ return ret
370
+
371
+
372
+ @dataclass
373
+ class LHECheck:
374
+ file: str
375
+ check_events: Iterable[LHECheckEventViolation]
376
+
377
+ def print(self, *args: Any, **kwargs: Any) -> LHECheckAccumulatedSummary:
378
+ ret = LHECheckAccumulatedSummary(
379
+ total_events_with_violations=0,
380
+ total_positive_mass_violations=0,
381
+ total_onshell_violations=0,
382
+ total_total_momentum_violations=0,
383
+ total_files_checked=1,
384
+ )
385
+ printed_header = False
386
+ for event in self.check_events:
387
+ if not printed_header:
388
+ print("-" * 60, *args, **kwargs)
389
+ print(f"File: {self.file}", *args, **kwargs)
390
+ printed_header = True
391
+ ret += event.print(*args, **kwargs)
392
+ return ret
393
+
394
+
395
+ def get_lhecheck(
396
+ filepath_or_fileobj: Union[str, TextIO],
397
+ lhecargs: LHECheckArgs,
398
+ ) -> LHECheck:
399
+ # Read LHE file
400
+ if isinstance(filepath_or_fileobj, str):
401
+ lhefile = pylhe.LHEFile.fromfile(filepath_or_fileobj)
402
+ file_display_name = filepath_or_fileobj
403
+ else:
404
+ lhefile = pylhe.LHEFile.frombuffer(filepath_or_fileobj)
405
+ file_display_name = "<stdin>"
406
+
407
+ def _generator() -> Iterable[LHECheckEventViolation]:
408
+ for event_index, event in enumerate(lhefile.events, start=1):
409
+ lhecheck_event = LHECheckEventViolation(
410
+ event_index=event_index,
411
+ particle_violations=[],
412
+ total_momentum_violations=None,
413
+ )
414
+ lhe_check_total_momenta = LHECheckTotalMomentaViolations(
415
+ incoming=[
416
+ LHEMomentum(
417
+ px=particle.px, py=particle.py, pz=particle.pz, e=particle.e
418
+ )
419
+ for particle in event.particles
420
+ if particle.status == -1
421
+ ], # Incoming
422
+ outgoing=[
423
+ LHEMomentum(
424
+ px=particle.px, py=particle.py, pz=particle.pz, e=particle.e
425
+ )
426
+ for particle in event.particles
427
+ if particle.status == 1
428
+ ], # Outgoing
429
+ )
430
+ if lhecargs.total_momentum and lhe_check_total_momenta.is_violation(
431
+ lhecargs.total_momentum_abs, lhecargs.total_momentum_rel
432
+ ):
433
+ lhecheck_event.total_momentum_violations = lhe_check_total_momenta
434
+
435
+ for particle_index, particle in enumerate(event.particles, start=1):
436
+ lhe_particle_check = LHECheckParticleViolation(
437
+ particle_index=particle_index,
438
+ particle_pdgid=particle.id,
439
+ on_shell_violations=None,
440
+ positive_mass_violation=None,
441
+ )
442
+ if lhecargs.positive_mass:
443
+ lhe_check_mass = LHECheckPositiveMassViolation(
444
+ px=particle.px, py=particle.py, pz=particle.pz, e=particle.e
445
+ )
446
+ if lhe_check_mass.is_violation(lhecargs.positive_mass_abs):
447
+ lhe_particle_check.positive_mass_violation = lhe_check_mass
448
+ if lhecargs.onshell and particle.status in [
449
+ -1,
450
+ 1,
451
+ ]: # Incoming or outgoing particles
452
+ lhe_check_onshell = LHECheckOnShellViolation(
453
+ px=particle.px,
454
+ py=particle.py,
455
+ pz=particle.pz,
456
+ e=particle.e,
457
+ m=particle.m,
458
+ )
459
+ if lhe_check_onshell.is_violation(
460
+ lhecargs.onshell_abs, lhecargs.onshell_rel
461
+ ):
462
+ lhe_particle_check.on_shell_violations = lhe_check_onshell
463
+ if lhe_particle_check.total_violations > 0:
464
+ lhecheck_event.particle_violations.append(lhe_particle_check)
465
+ if lhecheck_event.total_violations > 0:
466
+ yield lhecheck_event
467
+
468
+ return LHECheck(
469
+ file=file_display_name,
470
+ check_events=_generator(),
471
+ )
472
+
473
+
474
+ @dataclass
475
+ class LHECheckSummary:
476
+ files: list[LHECheck]
477
+
478
+ def print(self, *args: Any, **kwargs: Any) -> LHECheckAccumulatedSummary:
479
+ ret = LHECheckAccumulatedSummary(
480
+ total_files_checked=0,
481
+ total_events_with_violations=0,
482
+ total_positive_mass_violations=0,
483
+ total_onshell_violations=0,
484
+ total_total_momentum_violations=0,
485
+ )
486
+ for lhecheck in self.files:
487
+ ret += lhecheck.print(*args, **kwargs)
488
+ return ret
489
+
490
+
491
+ def get_lhechecksummary(
492
+ filepaths_or_fileobjs: list[Union[str, TextIO]],
493
+ lhecargs: LHECheckArgs,
494
+ ) -> LHECheckSummary:
495
+ lhechecks = []
496
+ for filepath_or_fileobj in filepaths_or_fileobjs:
497
+ lhecheck = get_lhecheck(filepath_or_fileobj, lhecargs)
498
+ lhechecks.append(lhecheck)
499
+ return LHECheckSummary(files=lhechecks)
500
+
501
+
502
+ def main() -> None:
503
+ """Main CLI function."""
504
+ parser = create_base_parser(
505
+ description="Validate LHE files and check momentum conservation",
506
+ formatter_class=argparse.RawDescriptionHelpFormatter,
507
+ epilog="""
508
+ Examples:
509
+ lhecheck file.lhe # Check with default thresholds (1e-6)
510
+ cat file.lhe | lhecheck # Read from stdin
511
+ lhecheck file.lhe --no-momentum # Skip momentum conservation checks
512
+ lhecheck file.lhe --no-onshell # Skip on-shell mass checks
513
+ lhecheck file.lhe -a 1e-10 -r 1e-8 -v # Custom thresholds with verbose output
514
+ cat file.lhe | lhecheck -v # Read from stdin with verbose JSON output
515
+ """,
516
+ )
517
+
518
+ parser.add_argument(
519
+ "files",
520
+ nargs="*",
521
+ help="LHE file(s) to validate (or read from stdin if not provided)",
522
+ )
523
+
524
+ parser.add_argument(
525
+ "--no-total-momentum",
526
+ action="store_true",
527
+ help="Skip total momentum conservation checks",
528
+ )
529
+ parser.add_argument(
530
+ "--total-momentum-rel",
531
+ type=positive_float,
532
+ default=1e-6,
533
+ help="Relative threshold for total momentum conservation (default: 1e-6)",
534
+ )
535
+ parser.add_argument(
536
+ "--total-momentum-abs",
537
+ type=positive_float,
538
+ default=1e-6,
539
+ help="Absolute threshold for total momentum conservation (default: 1e-6)",
540
+ )
541
+
542
+ parser.add_argument(
543
+ "--no-onshell",
544
+ action="store_true",
545
+ help="Skip on-shell mass checks",
546
+ )
547
+ parser.add_argument(
548
+ "--onshell-rel",
549
+ type=positive_float,
550
+ default=1e-6,
551
+ help="Relative threshold for on-shell mass checks (default: 1e-6)",
552
+ )
553
+ parser.add_argument(
554
+ "--onshell-abs",
555
+ type=positive_float,
556
+ default=1e-6,
557
+ help="Absolute threshold for on-shell mass checks (default: 1e-6)",
558
+ )
559
+
560
+ parser.add_argument(
561
+ "--no-positive-mass",
562
+ action="store_true",
563
+ help="Skip positive mass checks",
564
+ )
565
+ parser.add_argument(
566
+ "--positive-mass-abs",
567
+ type=positive_float,
568
+ default=1e-6,
569
+ help="Absolute threshold for positive mass checks (default: 1e-6)",
570
+ )
571
+
572
+ args = parser.parse_args()
573
+
574
+ # Check if reading from stdin
575
+ use_stdin = not args.files and not sys.stdin.isatty()
576
+
577
+ file_inputs: list[Union[str, TextIO]] = []
578
+ if use_stdin:
579
+ # Read from stdin
580
+ file_inputs += [sys.stdin]
581
+ else:
582
+ # Expand file paths
583
+ for pattern in args.files:
584
+ path = Path(pattern)
585
+ if path.exists():
586
+ if path.is_file():
587
+ file_inputs.append(str(path))
588
+ else:
589
+ warnings.warn(f"{pattern} is not a file", UserWarning, stacklevel=2)
590
+ else:
591
+ warnings.warn(f"{pattern} not found", UserWarning, stacklevel=2)
592
+
593
+ if not file_inputs:
594
+ print("Error: No valid files found and no stdin data", file=sys.stderr)
595
+ sys.exit(1)
596
+
597
+ lhecargs = LHECheckArgs(
598
+ positive_mass=not args.no_positive_mass,
599
+ positive_mass_abs=args.positive_mass_abs,
600
+ onshell=not args.no_onshell,
601
+ onshell_rel=args.onshell_rel,
602
+ onshell_abs=args.onshell_abs,
603
+ total_momentum=not args.no_total_momentum,
604
+ total_momentum_rel=args.total_momentum_rel,
605
+ total_momentum_abs=args.total_momentum_abs,
606
+ )
607
+
608
+ lhecheck_summary = get_lhechecksummary(file_inputs, lhecargs)
609
+ lheas = lhecheck_summary.print()
610
+ if lheas.total_violations != 0:
611
+ print("=" * 60)
612
+ lheas.print()
613
+
614
+ # Exit with appropriate code
615
+ sys.exit(0 if lheas.total_violations == 0 else 1)
616
+
617
+
618
+ if __name__ == "__main__":
619
+ main()