From 3f4b0654995bbcf8bb622e56a8d75759a2ed529d Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 9 Oct 2026 10:33:51 +0200 Subject: [PATCH 1/3] mg7 launch: translate madevent cut names in "set" (set mmll 200, ...) MG7Selector.do_set translated the madevent scalar names (_LO_SCALAR_MAP) but not the cut names, so "set mmll 200" in a launch script printed "invalid set command" and the run went ahead without the cut. Cut names in RunCardMG7._LO_CUT_MAP now go through the new RunCardMG7.set_lo_cut, which writes the [cuts] entry that from_LO uses (never [histograms], which shares the key syntax). A minimum <= 0 or a negative maximum removes the bound, as in from_LO, which now shares the rule through _lo_cut_is_active. A cut on a _LO_CUT_GROUPS group (ihtmin, ptllmin, mmnl) also adds that group to [multiparticles] when the card lacks it; without it the launch fails with KeyError: 'parton'. Checked end to end: p p > e+ e- with set mmll 200 / mmllmax 1000 gives 1.455 pb (891.9 pb once both are reset to 0 / -1); p p > e+ e- j with mmll 60 goes from 226.3 pb to 13.88 pb with ihtmin 100 and ptllmin 50. --- docs/source/madevent_to_mg7.rst | 8 +++ madgraph/iolibs/template_files/mg7/launch.py | 20 +++++++ madgraph/various/banner.py | 41 ++++++++++++- tests/unit_tests/interface/test_mg7_launch.py | 38 ++++++++++++ tests/unit_tests/various/test_banner.py | 59 +++++++++++++++++++ 5 files changed, 163 insertions(+), 3 deletions(-) diff --git a/docs/source/madevent_to_mg7.rst b/docs/source/madevent_to_mg7.rst index 7419a4f459..139db23707 100644 --- a/docs/source/madevent_to_mg7.rst +++ b/docs/source/madevent_to_mg7.rst @@ -17,6 +17,14 @@ and ``use_syst``. The shortcuts ``set lhc``, ``set lep``, ``set fixed_scale``, ``set no_parton_cut`` and ``set dynamical_scale_choice HT/n`` work as before, and so does ``set iseed``. MadEvent draws a random seed for ``iseed = 0``. MG7 does the same for ``seed = -1`` and treats ``0`` as an ordinary fixed seed, so ``set iseed 0`` is translated. + +The cut names of the `Cuts`_ table are translated as well, so ``set mmll 200`` sets +``lepton-sfos_pair_mass.min`` and ``set ptj1min 50`` sets ``jet_1-pt.min``. As in +``run_card.dat``, a minimum of zero or a negative maximum removes that bound. A cut on the +``parton`` or ``alllepton`` group also adds the group to ``[multiparticles]`` if it is not +there yet. ``ptheavy`` and the per-pdg cuts (``pt_min_pdg``, ``mxx_min_pdg``, ...) are not +translated, because they need a group of your own. + All other settings must use their new names. Beams and PDFs diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 2aa49beede..4290180203 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -3536,6 +3536,26 @@ def do_set(self, line, *args, **kwargs): self.modified_card.add("run") return + # legacy madevent cut names ("set ptj 20", "set mmll 200", + # "set etaj -1", ...) -> the [cuts] entry the LO->MG7 + # run_card conversion maps them to (_LO_CUT_MAP), with the + # same "no cut" sentinels (minimum <= 0, maximum < 0). + if rest and nlow in run_card._LO_CUT_MAP: + try: + cut, bound, val = run_card.set_lo_cut( + nlow, run_card.evaluate(rest, masses)) + except (_banner_mod.InvalidCmd, TypeError, ValueError): + logger.warning("ignoring 'set %s %s': not a number", nlow, rest) + return + if val is None: + logger.info("set %s (mg7 cut %s.%s) of the run_card.toml: " + "no cut", nlow, cut, bound) + else: + logger.info("set %s (mg7 cut %s.%s) of the run_card.toml to %s", + nlow, cut, bound, val) + self.modified_card.add("run") + return + # 'iseed' is the madevent spelling of the mg7 run.seed, # but the two disagree on how to ask for a random seed: # madevent uses 0, mg7 uses -1 (0 being a perfectly good diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 407316f4b6..a3fa1d87f9 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -7298,6 +7298,41 @@ def set_cut(self, name, value): self.dynamic_sections['cuts'].setdefault(cut, collections.OrderedDict())[bound] = value return cut, bound, value + @staticmethod + def _lo_cut_is_active(bound, value): + """Whether a madevent cut value imposes a constraint: a minimum must be + positive, a maximum non-negative (-1 is madevent's "no cut").""" + return (bound == 'min' and value > 0) or (bound == 'max' and value >= 0) + + def set_lo_cut(self, name, value): + """Set a cut from its madevent run_card name (``ptj``, ``mmll``, + ``etaj``, ``mmllmax``, ... see ``_LO_CUT_MAP``) in the ``[cuts]`` + section, with the same conventions as :meth:`from_LO`: a "no cut" + value (minimum <= 0, maximum < 0) removes that bound, and the cut + itself once it has no bound left. A cut on one of the + ``_LO_CUT_GROUPS`` (``ihtmin``, ``ptllmin``, ...) also defines that + group in ``[multiparticles]``, unless the card already has it. + Returns ``(cut, bound, value)``, with value None when the bound was + removed.""" + cut, bound = self._LO_CUT_MAP[name.lower()] + value = self.parse_energy(value) + if isinstance(value, str): + value = self.format_variable(value, float, name=name) + value = float(value) + cuts = self.dynamic_sections['cuts'] + if self._lo_cut_is_active(bound, value): + cuts.setdefault(cut, collections.OrderedDict())[bound] = value + multiparticles = self.dynamic_sections['multiparticles'] + for group, build in self._LO_CUT_GROUPS.items(): + if cut.startswith(group + '-') and group not in multiparticles: + multiparticles[group] = build(multiparticles) + return cut, bound, value + if cut in cuts: + cuts[cut].pop(bound, None) + if not cuts[cut]: + del cuts[cut] + return cut, bound, None + # ------------------------------------------------------------------ # expression evaluation (energy units + arithmetic + masses) # ------------------------------------------------------------------ @@ -8172,9 +8207,9 @@ def add_cut(cutkey, bound, val): val = max(entry[bound], val) if bound == 'min' else min(entry[bound], val) entry[bound] = float(val) - def is_active(bound, val): - # LO: a minimum of 0 and a maximum below 0 switch the cut off - return (bound == 'min' and val > 0) or (bound == 'max' and val >= 0) + # LO: a minimum of 0 and a maximum below 0 switch the cut off (shared + # with set_lo_cut, i.e. "set ptj 0" in the launch question) + is_active = cls._lo_cut_is_active for loname, (cutkey, bound) in cls._LO_CUT_MAP.items(): if loname in lo and is_active(bound, lo[loname]): diff --git a/tests/unit_tests/interface/test_mg7_launch.py b/tests/unit_tests/interface/test_mg7_launch.py index 9eb5e49aaa..e65f53cfce 100644 --- a/tests/unit_tests/interface/test_mg7_launch.py +++ b/tests/unit_tests/interface/test_mg7_launch.py @@ -676,6 +676,44 @@ def test_set_histograms_rejects_anything_else(self): self.assertEqual(dict(obj.run_card['histograms']), before) self.assertNotIn('run', obj.modified_card) + # -- "set ..." in the launch question --------------- + def cut_selector(self): + """histogram_selector() whose generic editor must not be reached: it + is what answered "WARNING: invalid set command mmll 200".""" + obj = self.histogram_selector() + generic = mock.patch.object(type(obj).__mro__[1], 'do_set') + self.generic_do_set = generic.start() + self.addCleanup(generic.stop) + return obj + + def test_set_madevent_cut_names(self): + """set mmll/mmllmax/... edit the [cuts] entry from_LO maps them to""" + obj = self.cut_selector() + obj.do_set('mmll 200') + self.assertEqual(dict(obj.run_card['cuts']['lepton-sfos_pair_mass']), + {'min': 200.0}) + self.assertIn('run', obj.modified_card) + obj.do_set('run_card mmllmax 2*100+100') + self.assertEqual(dict(obj.run_card['cuts']['lepton-sfos_pair_mass']), + {'min': 200.0, 'max': 300.0}) + obj.do_set('mmll 0') + obj.do_set('mmllmax -1') + self.assertNotIn('lepton-sfos_pair_mass', obj.run_card['cuts']) + # sqrt_s is also a histogram name: only the cut moves + obj.do_set('dsqrt_shat 500') + self.assertEqual(dict(obj.run_card['cuts']['sqrt_s']), {'min': 500.0}) + self.assertEqual(dict(obj.run_card['histograms']['sqrt_s']), + {'min': 0., 'max': 2000., 'bin_count': 50}) + self.generic_do_set.assert_not_called() + + def test_set_madevent_cut_name_rejects_non_numbers(self): + obj = self.cut_selector() + before = dict(obj.run_card['cuts']) + obj.do_set('ptj abc') + self.assertEqual(dict(obj.run_card['cuts']), before) + self.assertNotIn('run', obj.modified_card) + self.generic_do_set.assert_not_called() + def test_no_post_processing_keeps_the_npy_output(self): self.assertEqual(self.output_format({}), 'compact_npy') self.assertEqual(self.output_format(None), 'compact_npy') diff --git a/tests/unit_tests/various/test_banner.py b/tests/unit_tests/various/test_banner.py index 74d3589937..4224847eb1 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -1596,6 +1596,65 @@ def test_set_cut_with_units(self): self.assertTrue(rc.is_cut_name('cuts.jet-pt.max')) self.assertFalse(rc.is_cut_name('generation.events')) + def test_set_lo_cut(self): + """madevent cut names land in [cuts] as from_LO maps them, the "no + cut" sentinels removing the bound; [histograms] is left alone""" + rc = bannermod.RunCardMG7() + rc.dynamic_sections['histograms']['sqrt_s'] = \ + {'min': 0., 'max': 2000., 'bin_count': 50} + self.assertEqual(rc.set_lo_cut('mmll', '200'), + ('lepton-sfos_pair_mass', 'min', 200.0)) + self.assertEqual(rc['cuts']['lepton-sfos_pair_mass'], {'min': 200.0}) + rc.set_lo_cut('MMLLMAX', '1 TeV') + self.assertEqual(rc['cuts']['lepton-sfos_pair_mass'], + {'min': 200.0, 'max': 1000.0}) + # minimum 0 / maximum -1 mean "no cut" + self.assertEqual(rc.set_lo_cut('mmll', 0), + ('lepton-sfos_pair_mass', 'min', None)) + self.assertEqual(rc['cuts']['lepton-sfos_pair_mass'], {'max': 1000.0}) + rc.set_lo_cut('mmllmax', -1) + self.assertNotIn('lepton-sfos_pair_mass', rc['cuts']) + # a maximum of 0 is a real cut, as in from_LO + rc.set_lo_cut('ptjmax', 0) + self.assertEqual(rc['cuts']['jet-pt'], {'min': 20.0, 'max': 0.0}) + rc.set_lo_cut('etaj', -1) + self.assertNotIn('jet-eta_abs', rc['cuts']) + # sqrt_s is a cut and a histogram under the same key + rc.set_lo_cut('dsqrt_shat', '500') + self.assertEqual(rc['cuts']['sqrt_s'], {'min': 500.0}) + self.assertEqual(rc['histograms']['sqrt_s'], + {'min': 0., 'max': 2000., 'bin_count': 50}) + self.assertRaises(bannermod.InvalidCmd, rc.set_lo_cut, 'ptj', 'abc') + + def test_set_lo_cut_composite(self): + """the ordered/summed cuts from_LO converts are settable too, and + bring the group they are written on when the card lacks it""" + rc = bannermod.RunCardMG7() + multiparticles = rc['multiparticles'] + self.assertNotIn('parton', multiparticles) + self.assertNotIn('alllepton', multiparticles) + rc.set_lo_cut('ptj1min', 50) + rc.set_lo_cut('ht3min', 200) + self.assertEqual(rc['cuts']['jet_1-pt'], {'min': 50.0}) + self.assertEqual(rc['cuts']['jet_1-jet_2-jet_3-pt-sum'], {'min': 200.0}) + self.assertNotIn('parton', multiparticles) + rc.set_lo_cut('ihtmin', 100) + self.assertEqual(rc['cuts']['parton-pt-sum'], {'min': 100.0}) + self.assertEqual(sorted(multiparticles['parton']), + [-5, -4, -3, -2, -1, 1, 2, 3, 4, 5, 21]) + rc.set_lo_cut('ptllmin', 30) + self.assertEqual(rc['cuts']['alllepton-sum-pt'], {'min': 30.0}) + self.assertEqual(list(multiparticles['alllepton']), + list(multiparticles['lepton']) + list(multiparticles['missing'])) + # a group the user defined is kept + multiparticles['alllepton'] = [11, -11] + rc.set_lo_cut('mmnl', 80) + self.assertEqual(multiparticles['alllepton'], [11, -11]) + # "no cut" removes the cut, not the group + rc.set_lo_cut('ihtmin', 0) + self.assertNotIn('parton-pt-sum', rc['cuts']) + self.assertIn('parton', multiparticles) + def test_evaluate_math_and_masses(self): """values support arithmetic and mass references""" rc = bannermod.RunCardMG7() From ce356c5669522503058e9ecf598ce4610743e1ef Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 10 Oct 2026 17:14:57 +0200 Subject: [PATCH 2/3] mg7 launch: translate the madevent beam, PDF and scale settings in "set" At the mg7 launch question, settings that the run_card.dat conversion (from_LO) handles were still lost: - "set ebeam1 6500", "set ebeam 6800", "set lpp1 0", "set pdlabel ...", "set lhaid ...", "set fixed_fac_scale ...", "set maxjetflavor 5" and the per-pdg cuts ("set pt_min_pdg {6: 100}") printed "invalid set command" and the run went on with the mg7 default; - "set dynamical_scale_choice 3" and "set sde_strategy 2" (madevent's integer codes) went to the mg7 key of the same name, which only takes names, and aborted a scripted launch with InvalidCardEdition. RunCardMG7.set_lo_param now translates them as from_LO does: the integer codes and the et/shat/ckkw shortcuts to the mg7 names (HT and HT/n stay with the generic editor), maxjetflavor to the jet group, the {pdg: value} cuts to a pdg group plus its cut (mxx_only_part_antipart choosing pair_mass or sfos_pair_mass, in either order), pdlabel/lhaid to beam.pdf through the same tables. Values with no mg7 equivalent warn and keep the current setting instead of aborting. mg7 has one energy, one PDF and one lepton flag for both beams, so the per-beam settings of one launch question are combined: ebeam1 and ebeam2 give e_cm = 2 sqrt(E1 E2), lpp1 and lpp2 give beam.leptonic. What still differs per beam is reported once, when the question closes (MG7Selector.check_card_consistency), so "set ebeam1 X" followed by "set ebeam2 X" stays quiet. PR #154 (one energy and one PDF per beam) routes the beam names to its per-beam keys through set_lo_beam_param; whichever merges second hands them over to it. from_LO now builds the jet and pdg groups with the same helpers. --- docs/source/madevent_to_mg7.rst | 14 +- madgraph/iolibs/template_files/mg7/launch.py | 38 +++ madgraph/various/banner.py | 281 +++++++++++++++++- tests/unit_tests/interface/test_mg7_launch.py | 43 +++ tests/unit_tests/various/test_banner.py | 127 ++++++++ 5 files changed, 487 insertions(+), 16 deletions(-) diff --git a/docs/source/madevent_to_mg7.rst b/docs/source/madevent_to_mg7.rst index 139db23707..9bfee8a4ec 100644 --- a/docs/source/madevent_to_mg7.rst +++ b/docs/source/madevent_to_mg7.rst @@ -22,8 +22,18 @@ The cut names of the `Cuts`_ table are translated as well, so ``set mmll 200`` s ``lepton-sfos_pair_mass.min`` and ``set ptj1min 50`` sets ``jet_1-pt.min``. As in ``run_card.dat``, a minimum of zero or a negative maximum removes that bound. A cut on the ``parton`` or ``alllepton`` group also adds the group to ``[multiparticles]`` if it is not -there yet. ``ptheavy`` and the per-pdg cuts (``pt_min_pdg``, ``mxx_min_pdg``, ...) are not -translated, because they need a group of your own. +there yet. The per-pdg cuts are written as in ``run_card.dat``, for example +``set pt_min_pdg {6: 100}``, and put on a group ``pdg6 = [6, -6]``. ``ptheavy`` is not +translated, because it needs a group of your own. + +The beam and PDF settings ``ebeam1``, ``ebeam2`` (and the shortcut ``set ebeam``), +``lpp1``, ``lpp2``, ``pdlabel``, ``pdlabel1``, ``pdlabel2``, ``lhaid``, ``fixed_fac_scale``, +``fixed_fac_scale1``, ``fixed_fac_scale2`` and ``maxjetflavor`` are translated as in the tables +below, and so are the numbers of ``dynamical_scale_choice`` (``set dynamical_scale_choice 3``) +and ``sde_strategy``. MG7 has one energy, one PDF and one lepton flag for both beams, so the +two values of a pair are combined: ``set ebeam1 7000`` followed by ``set ebeam2 4000`` runs at +the same :math:`\sqrt{s}` in the centre-of-mass frame. What cannot be represented this way is +reported once the launch question closes. All other settings must use their new names. diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 4290180203..6cc275ac60 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -3470,6 +3470,15 @@ def init_run(self, cards): self.run_card.allow_scan = True return getattr(self, "run_set", out) + def check_card_consistency(self): + super().check_card_consistency() + # madevent settings given per beam ("set ebeam1 ...") that the + # one-energy, one-PDF mg7 card could not follow: said once, at + # the end, so "set ebeam1 X" then "set ebeam2 X" stays quiet + if isinstance(getattr(self, "run_card", None), RunCardMG7): + for text in self.run_card.lo_set_warnings(): + logger.warning(text) + def do_set(self, line, *args, **kwargs): # madevent-style shortcuts (lhc/lep/fixed_scale/no_parton_cut), cut # editing, energy units and arithmetic/mass expressions on the TOML @@ -3556,6 +3565,35 @@ def do_set(self, line, *args, **kwargs): self.modified_card.add("run") return + # madevent parameters that are not a plain rename: the + # value is translated ("set dynamical_scale_choice 3") or + # several of them make one mg7 entry (ebeam1 + ebeam2 -> + # beam.e_cm). mg7 values ("set sde_strategy denominators") + # come back as None and go on to the generic editor. + if rest and nlow in run_card._LO_SET_PARAMS: + try: + done = run_card.set_lo_param(nlow, rest, masses) + except (_banner_mod.InvalidCmd, TypeError, ValueError) as error: + logger.warning("ignoring 'set %s %s': %s", nlow, rest, error) + return + if done is not None: + changes, warnings = done + for key, val in changes.items(): + if val is None: + logger.info("set %s: removed %s from the run_card.toml", + nlow, key) + else: + logger.info("set %s (mg7 %s) of the run_card.toml to %s", + nlow, key, val) + for text in warnings: + logger.warning(text) + if changes: + self.modified_card.add("run") + elif not warnings: + logger.info("set %s %s: nothing to change in the run_card.toml", + nlow, rest) + return + # 'iseed' is the madevent spelling of the mg7 run.seed, # but the two disagree on how to ask for a random seed: # madevent uses 0, mg7 uses -1 (0 being a perfectly good diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index a3fa1d87f9..b2d9e8068f 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -7318,20 +7318,263 @@ def set_lo_cut(self, name, value): value = self.parse_energy(value) if isinstance(value, str): value = self.format_variable(value, float, name=name) - value = float(value) - cuts = self.dynamic_sections['cuts'] - if self._lo_cut_is_active(bound, value): - cuts.setdefault(cut, collections.OrderedDict())[bound] = value + value = self._put_lo_cut(cut, bound, float(value)) + if value is not None: multiparticles = self.dynamic_sections['multiparticles'] for group, build in self._LO_CUT_GROUPS.items(): if cut.startswith(group + '-') and group not in multiparticles: multiparticles[group] = build(multiparticles) - return cut, bound, value + return cut, bound, value + + def _put_lo_cut(self, cut, bound, value): + """Set one bound of a [cuts] entry, or remove it (and the entry once + it has no bound left) for a madevent "no cut" value. Returns the value + set, None when the bound was removed.""" + cuts = self.dynamic_sections['cuts'] + if self._lo_cut_is_active(bound, value): + cuts.setdefault(cut, collections.OrderedDict())[bound] = value + return value if cut in cuts: cuts[cut].pop(bound, None) if not cuts[cut]: del cuts[cut] - return cut, bound, None + return None + + @staticmethod + def _lo_jet_group(maxjetflavor): + """The jet group of a madevent maxjetflavor: the quarks up to that + flavour, their antiquarks and the gluon.""" + return list(range(1, maxjetflavor + 1)) + \ + [-i for i in range(1, maxjetflavor + 1)] + [21] + + @staticmethod + def _lo_pdg_group(pdg): + """Name and content of the group a madevent per-pdg cut is put on.""" + return 'pdg%d' % abs(pdg), [abs(pdg), -abs(pdg)] + + @staticmethod + def _lo_pair_observable(pdg, only_part_antipart): + """Observable of mxx_min_pdg for this pdg: X X~ pairs only when + mxx_only_part_antipart says so (for it, or by default).""" + return 'sfos_pair_mass' if only_part_antipart.get( + pdg, only_part_antipart.get('default', False)) else 'pair_mass' + + def set_lo_param(self, name, value, masses=None): + """Apply ``set `` for a madevent parameter that mg7 does + not just rename (``_LO_SET_PARAMS``): its value needs translating + (``dynamical_scale_choice 3``, ``pdlabel nn23lo``, ...), or several + madevent settings make one mg7 entry (ebeam1 and ebeam2 give + beam.e_cm). Those are combined with what the earlier ``set`` commands + of the same launch question gave, the card supplying the rest, and + :meth:`lo_set_warnings` reports what still has no mg7 equivalent once + the question closes. + + Returns None when ``value`` is already an mg7 value (``set + sde_strategy denominators``, ``set dynamical_scale_choice HT/4``), + for the caller to set as usual. Otherwise returns ``(changes, + warnings)``: the {mg7 key: new value} written (None for a removed + cut) and what of the request could not be followed. Raises + InvalidCmd for a value of the wrong type.""" + name = name.lower() + text = str(value).split('#')[0].strip() + state = vars(self).setdefault('_lo_set_state', {}) + beam = self['beam'] + changes, warnings = collections.OrderedDict(), [] + + def put(key, val): + self.set(key, val, user=True) + changes[key] = self[key] + + def number(val): + val = self.evaluate(val, masses) + if isinstance(val, str): + val = self.format_variable(val, float, name=name) + return float(val) + + if name in ('dynamical_scale_choice', 'sde_strategy'): + code = text + if name == 'dynamical_scale_choice': + code = str(self._LO_DYNSCALE_SHORTCUTS.get(text.lower(), text)) + if not re.match(r'^[-+]?\d+$', code): + return None + code = int(code) + if name == 'sde_strategy': + if code in (1, 2): + put('phasespace.sde_strategy', + 'denominators' if code == 2 else 'diagrams') + else: + warnings.append( + 'sde_strategy %d does not exist (1 = diagrams, ' + '2 = denominators): keeping %s' + % (code, self['phasespace.sde_strategy'])) + elif code in self._LO_DYNSCALE_MAP: + put('beam.dynamical_scale_choice', self._LO_DYNSCALE_MAP[code]) + else: + what = {-1: ' (CKKW back-clustering)', + 0: ' (user-defined scale)'}.get(code, '') + warnings.append( + 'dynamical_scale_choice %d%s has no mg7 equivalent: ' + 'keeping %s' % (code, what, beam['dynamical_scale_choice'])) + + elif name in ('ebeam', 'ebeam1', 'ebeam2'): + energy = number(text) + if energy <= 0: + raise InvalidCmd('%s must be positive' % name) + e_cm = float(beam['e_cm']) + energies = state.get('ebeam') + # start from the card again if its energy was changed another way + if not energies or not math.isclose( + 2. * math.sqrt(energies[0] * energies[1]), e_cm): + energies = state['ebeam'] = [e_cm / 2., e_cm / 2.] + for i in ((0, 1) if name == 'ebeam' else (int(name[-1]) - 1,)): + energies[i] = energy + put('beam.e_cm', 2. * math.sqrt(energies[0] * energies[1])) + + elif name in ('lpp1', 'lpp2'): + lpp = self.format_variable(text, int, name=name) + leptonic = lambda lpps: all(l == 0 or abs(l) in (3, 4) for l in lpps) + lpps = state.get('lpp') + if not lpps or leptonic(lpps) != beam['leptonic']: + lpps = state['lpp'] = [0, 0] if beam['leptonic'] else [1, 1] + lpps[int(name[-1]) - 1] = lpp + if abs(lpp) in (3, 4): + warnings.append('%s=%d: mg7 lepton beams have no PDF, so there ' + 'is no initial-state radiation' % (name, lpp)) + elif lpp == -1: + warnings.append('%s=-1: mg7 has no antiproton beam, both beams ' + 'use beam.pdf' % name) + elif lpp not in (0, 1): + warnings.append('%s=%d: no such beam in mg7 (1 = proton, ' + '0 = no PDF)' % (name, lpp)) + put('beam.leptonic', leptonic(lpps)) + + elif name in ('pdlabel', 'pdlabel1', 'pdlabel2', 'lhaid'): + labels = state.setdefault('pdlabel', {}) + if name == 'lhaid': + state['lhaid'] = self.format_variable( + text.replace(',', ' ').split()[0] if text else text, + int, name=name) + others = sorted(set(labels.values()) - {'lhapdf'}) + if others: + warnings.append('lhaid only applies with pdlabel lhapdf, ' + 'not %s: keeping beam.pdf = %s' + % (others[0], beam['pdf'])) + return changes, warnings + label = 'lhapdf' + else: + label = text.lower() + for i in ((1, 2) if name == 'pdlabel' else (int(name[-1]),)): + labels[i] = label + lhaid = state.get('lhaid') + if label == 'lhapdf' and lhaid is not None: + if lhaid in self._LO_LHAID_MAP: + put('beam.pdf', self._LO_LHAID_MAP[lhaid]) + else: + warnings.append('lhaid=%s is not an id mg7 knows: give the ' + 'LHAPDF set name with "set beam.pdf " ' + '(keeping %s)' % (lhaid, beam['pdf'])) + elif label in self._LO_PDF_LABEL_MAP: + put('beam.pdf', self._LO_PDF_LABEL_MAP[label]) + elif label in ('none', 'no'): + warnings.append('%s=%s: beams without PDF are "set ' + 'beam.leptonic True" in mg7 (keeping beam.pdf ' + '= %s)' % (name, label, beam['pdf'])) + elif label != 'lhapdf': + warnings.append('%s=%s has no mg7 equivalent: keeping ' + 'beam.pdf = %s' % (name, label, beam['pdf'])) + + elif name in ('fixed_fac_scale', 'fixed_fac_scale1', 'fixed_fac_scale2'): + flag = self.format_variable(text, bool, name=name) + current = beam['fixed_fact_scale'] + flags = state.setdefault('fixed_fac_scale', {1: current, 2: current}) + for i in ((1, 2) if name == 'fixed_fac_scale' else (int(name[-1]),)): + flags[i] = flag + put('beam.fixed_fact_scale', flag) + + elif name == 'maxjetflavor': + flavours = self.format_variable(text, int, name=name) + if not 0 <= flavours <= 6: + raise InvalidCmd('maxjetflavor must be between 0 and 6') + jet = self._lo_jet_group(flavours) + self.dynamic_sections['multiparticles']['jet'] = jet + changes['multiparticles.jet'] = jet + + else: + # per-pdg cuts, written {pdg: value, ...} as in the run_card.dat + items = [] + for item in text.strip().lstrip('{').rstrip('}').split(','): + if not item.strip(): + continue + if ':' not in item: + raise InvalidCmd('%s takes {pdg: value, ...}, not %s' + % (name, text)) + key, val = (x.strip().strip('\'"') for x in item.split(':', 1)) + if key.lower() != 'default': + key = self.format_variable(key, int, name=name) + items.append((key, val)) + cuts = self.dynamic_sections['cuts'] + multiparticles = self.dynamic_sections['multiparticles'] + only_pairs = state.setdefault('mxx_only_part_antipart', {}) + if name == 'mxx_only_part_antipart': + for key, val in items: + only_pairs[key] = self.format_variable(val, bool, name=name) + # existing pair-mass cuts follow the new choice + for cut in list(cuts): + match = re.match(r'^pdg(\d+)-(sfos_)?pair_mass$', cut) + if not match: + continue + pdg = int(match.group(1)) + new = 'pdg%d-%s' % (pdg, self._lo_pair_observable(pdg, only_pairs)) + if new != cut: + cuts[new] = cuts.pop(cut) + changes['cuts.' + cut] = None + changes['cuts.' + new] = cuts[new] + else: + for pdg, val in items: + if pdg == 'default': + continue + if name == 'mxx_min_pdg': + observable = self._lo_pair_observable(pdg, only_pairs) + bound = 'min' + else: + observable, bound = self._LO_PDG_CUT_MAP[name] + group, pids = self._lo_pdg_group(pdg) + cut = '%s-%s' % (group, observable) + if self._put_lo_cut(cut, bound, number(val)) is not None: + multiparticles[group] = pids + changes['cuts.' + cut] = cuts.get(cut) + return changes, warnings + + def lo_set_warnings(self): + """What the madevent-style ``set`` commands of a launch question asked + for that mg7, with one energy, one PDF and one lepton flag for both + beams, cannot do. Meant for when the question closes: the combined + settings are forgotten afterwards, so the next question starts over.""" + state = vars(self).pop('_lo_set_state', {}) + beam = self['beam'] + out = [] + energies = state.get('ebeam') + if energies and not math.isclose(*energies) and math.isclose( + 2. * math.sqrt(energies[0] * energies[1]), float(beam['e_cm'])): + out.append('ebeam1 = %g GeV and ebeam2 = %g GeV: mg7 collides beams ' + 'of equal energy, so this runs at the same sqrt(s) = %g ' + 'GeV in the centre-of-mass frame, without the boost' + % (energies[0], energies[1], float(beam['e_cm']))) + lpps = state.get('lpp') + if lpps and len({l == 0 or abs(l) in (3, 4) for l in lpps}) > 1: + out.append('lpp1 = %d and lpp2 = %d: mg7 has one beam.leptonic for ' + 'both beams (%s)' % (lpps[0], lpps[1], beam['leptonic'])) + labels = state.get('pdlabel', {}) + if labels.get(1, labels.get(2)) != labels.get(2, labels.get(1)): + out.append('pdlabel1 = %s and pdlabel2 = %s: mg7 uses one PDF for ' + 'both beams (beam.pdf = %s)' + % (labels[1], labels[2], beam['pdf'])) + flags = state.get('fixed_fac_scale') + if flags and flags[1] != flags[2]: + out.append('fixed_fac_scale1 = %s and fixed_fac_scale2 = %s: mg7 has ' + 'one beam.fixed_fact_scale for both beams (%s)' + % (flags[1], flags[2], beam['fixed_fact_scale'])) + return out # ------------------------------------------------------------------ # expression evaluation (energy units + arithmetic + masses) @@ -7992,6 +8235,9 @@ def remove_jet_cuts(self): # LO dynamical_scale_choice (int) -> MG7 string _LO_DYNSCALE_MAP = {1: 'transverse_energy', 2: 'transverse_mass', 3: 'half_transverse_mass', 4: 'partonic_energy'} + # the LO run_card's names for some of those codes ('ht' and 'ht/n' are + # handled by the launch question itself) + _LO_DYNSCALE_SHORTCUTS = {'et': 1, 'shat': 4, 'ckkw': -1} # LO cut parameter -> (MG7 cut key, bound) _LO_CUT_MAP = { 'ptj': ('jet-pt', 'min'), 'ptjmax': ('jet-pt', 'max'), @@ -8054,6 +8300,15 @@ def remove_jet_cuts(self): 'e_min_pdg': ('e', 'min'), 'e_max_pdg': ('e', 'max'), 'eta_min_pdg': ('eta_abs', 'min'), 'eta_max_pdg': ('eta_abs', 'max'), } + # LO parameters that "set" translates with set_lo_param: not a plain + # rename (_LO_SCALAR_MAP) nor a cut (_LO_CUT_MAP); "ebeam" is madevent's + # shortcut for both beams. A set literal: set() is the method of this + # class here. + _LO_SET_PARAMS = {'ebeam', 'ebeam1', 'ebeam2', 'lpp1', 'lpp2', 'pdlabel', 'pdlabel1', + 'pdlabel2', 'lhaid', 'fixed_fac_scale', 'fixed_fac_scale1', + 'fixed_fac_scale2', 'dynamical_scale_choice', 'sde_strategy', + 'maxjetflavor', 'mxx_min_pdg', 'mxx_only_part_antipart', + *_LO_PDG_CUT_MAP} # built-in LO pdlabel -> LHAPDF set name _LO_PDF_LABEL_MAP = { 'nn23lo': 'NNPDF23_lo_as_0130_qed', 'nn23lo1': 'NNPDF23_lo_as_0130_qed', @@ -8191,9 +8446,8 @@ def nondefault(name): # --- maxjetflavor -> jet multiparticle --- if 'maxjetflavor' in lo: - mjf = int(lo['maxjetflavor']) mg7.dynamic_sections['multiparticles']['jet'] = \ - list(range(1, mjf + 1)) + [-i for i in range(1, mjf + 1)] + [21] + cls._lo_jet_group(int(lo['maxjetflavor'])) # --- cuts (rebuild from the LO card) --- cuts = collections.OrderedDict() @@ -8220,18 +8474,17 @@ def add_cut(cutkey, bound, val): for loname, (observable, bound) in cls._LO_PDG_CUT_MAP.items(): for pdg, val in lo[loname].items() if loname in lo else []: if isinstance(pdg, int) and is_active(bound, val): - group = 'pdg%d' % abs(pdg) - multiparticles[group] = [abs(pdg), -abs(pdg)] + group, pids = cls._lo_pdg_group(pdg) + multiparticles[group] = pids add_cut('%s-%s' % (group, observable), bound, val) if 'mxx_min_pdg' in lo: only_pairs = lo['mxx_only_part_antipart'] if 'mxx_only_part_antipart' in lo else {} for pdg, val in lo['mxx_min_pdg'].items(): if isinstance(pdg, int) and is_active('min', val): - group = 'pdg%d' % abs(pdg) - multiparticles[group] = [abs(pdg), -abs(pdg)] + group, pids = cls._lo_pdg_group(pdg) + multiparticles[group] = pids # X X~ pairs only, or every pair of the group - observable = 'sfos_pair_mass' if only_pairs.get( - pdg, only_pairs.get('default', False)) else 'pair_mass' + observable = cls._lo_pair_observable(pdg, only_pairs) add_cut('%s-%s' % (group, observable), 'min', val) for group, build in cls._LO_CUT_GROUPS.items(): diff --git a/tests/unit_tests/interface/test_mg7_launch.py b/tests/unit_tests/interface/test_mg7_launch.py index e65f53cfce..83ee96d37c 100644 --- a/tests/unit_tests/interface/test_mg7_launch.py +++ b/tests/unit_tests/interface/test_mg7_launch.py @@ -714,6 +714,49 @@ def test_set_madevent_cut_name_rejects_non_numbers(self): self.assertNotIn('run', obj.modified_card) self.generic_do_set.assert_not_called() + def test_set_madevent_beam_and_scale_names(self): + """ebeam, the integer scale/strategy codes, maxjetflavor and the + per-pdg cuts are translated. The generic editor rejected the first + and aborted a launch script on the integer codes.""" + obj = self.cut_selector() + obj.do_set('ebeam1 6800') + obj.do_set('run_card ebeam2 6800') + self.assertAlmostEqual(obj.run_card['beam']['e_cm'], 13600.) + obj.do_set('ebeam 7 TeV') + self.assertAlmostEqual(obj.run_card['beam']['e_cm'], 14000.) + obj.do_set('dynamical_scale_choice 1') + self.assertEqual(obj.run_card['beam']['dynamical_scale_choice'], + 'transverse_energy') + obj.do_set('dynamical_scale_choice 0') # no equivalent: kept + self.assertEqual(obj.run_card['beam']['dynamical_scale_choice'], + 'transverse_energy') + obj.do_set('sde_strategy 2') + self.assertEqual(obj.run_card['phasespace']['sde_strategy'], 'denominators') + obj.do_set('maxjetflavor 5') + self.assertIn(5, obj.run_card['multiparticles']['jet']) + obj.do_set('pt_min_pdg {6: 100}') + self.assertEqual(dict(obj.run_card['cuts']['pdg6-pt']), {'min': 100.0}) + self.assertIn('run', obj.modified_card) + self.generic_do_set.assert_not_called() + + def test_set_mg7_values_still_reach_the_editor(self): + obj = self.cut_selector() + obj.do_set('dynamical_scale_choice transverse_mass') + obj.do_set('sde_strategy denominators') + obj.do_set('dynamical_scale_choice HT/4') + self.assertEqual(self.generic_do_set.call_count, 3) + + def test_asymmetric_beams_are_reported_when_the_question_closes(self): + obj = self.cut_selector() + parent = type(obj).__mro__[1] + with mock.patch.object(parent, 'check_card_consistency'): + obj.do_set('ebeam1 7000') + obj.do_set('ebeam2 4000') + with self.assertLogs('madgraph7', level='WARNING') as logs: + obj.check_card_consistency() + self.assertEqual(len(logs.output), 1) + self.assertIn('ebeam1 = 7000 GeV and ebeam2 = 4000 GeV', logs.output[0]) + def test_no_post_processing_keeps_the_npy_output(self): self.assertEqual(self.output_format({}), 'compact_npy') self.assertEqual(self.output_format(None), 'compact_npy') diff --git a/tests/unit_tests/various/test_banner.py b/tests/unit_tests/various/test_banner.py index 4224847eb1..15e73d3506 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -1655,6 +1655,133 @@ def test_set_lo_cut_composite(self): self.assertNotIn('parton-pt-sum', rc['cuts']) self.assertIn('parton', multiparticles) + def test_set_lo_param_value_translation(self): + """madevent codes become the mg7 names; mg7 values are left alone""" + rc = bannermod.RunCardMG7() + self.assertEqual(rc.set_lo_param('dynamical_scale_choice', '1'), + ({'beam.dynamical_scale_choice': 'transverse_energy'}, [])) + rc.set_lo_param('dynamical_scale_choice', 'shat') + self.assertEqual(rc['beam.dynamical_scale_choice'], 'partonic_energy') + # no mg7 equivalent: a warning, the value is kept + changes, warnings = rc.set_lo_param('dynamical_scale_choice', '0') + self.assertEqual(changes, {}) + self.assertIn('user-defined', warnings[0]) + self.assertEqual(rc['beam.dynamical_scale_choice'], 'partonic_energy') + self.assertIsNone(rc.set_lo_param('dynamical_scale_choice', 'transverse_mass')) + self.assertIsNone(rc.set_lo_param('dynamical_scale_choice', 'HT/4')) + rc.set_lo_param('sde_strategy', '2') + self.assertEqual(rc['phasespace.sde_strategy'], 'denominators') + rc.set_lo_param('sde_strategy', '1') + self.assertEqual(rc['phasespace.sde_strategy'], 'diagrams') + self.assertEqual(len(rc.set_lo_param('sde_strategy', '3')[1]), 1) + self.assertIsNone(rc.set_lo_param('sde_strategy', 'denominators')) + rc.set_lo_param('maxjetflavor', '5') + self.assertEqual(sorted(rc['multiparticles']['jet']), + [-5, -4, -3, -2, -1, 1, 2, 3, 4, 5, 21]) + rc.set_lo_param('fixed_fac_scale', 'T') + self.assertIs(rc['beam.fixed_fact_scale'], True) + rc.set_lo_param('pdlabel', 'nn23lo') + self.assertEqual(rc['beam.pdf'], 'NNPDF23_lo_as_0130_qed') + # as in madevent, lhaid only counts with pdlabel lhapdf + changes, warnings = rc.set_lo_param('lhaid', '338500') + self.assertEqual((changes, rc['beam.pdf']), ({}, 'NNPDF23_lo_as_0130_qed')) + self.assertIn('pdlabel lhapdf', warnings[0]) + rc.set_lo_param('pdlabel', 'lhapdf') + self.assertEqual(rc['beam.pdf'], 'NNPDF40MC_lo_as_01180') + changes, warnings = rc.set_lo_param('lhaid', '260000') + self.assertEqual((changes, rc['beam.pdf']), ({}, 'NNPDF40MC_lo_as_01180')) + self.assertIn('set beam.pdf', warnings[0]) + self.assertRaises(bannermod.InvalidCmd, rc.set_lo_param, 'maxjetflavor', '9') + self.assertRaises(bannermod.InvalidCmd, rc.set_lo_param, 'ebeam1', 'abc') + self.assertEqual(rc.lo_set_warnings(), []) + + def test_set_lo_param_beams_combine(self): + """per-beam settings combine within one question; what one mg7 entry + cannot hold is reported once, at the end""" + rc = bannermod.RunCardMG7() + rc.set('beam.e_cm', 13000., user=True) + # the second beam stays at its 6500 GeV until it is set too + rc.set_lo_param('ebeam1', '6800') + self.assertAlmostEqual(rc['beam.e_cm'], 2 * (6800. * 6500.) ** 0.5) + rc.set_lo_param('ebeam2', '6.8 TeV') + self.assertAlmostEqual(rc['beam.e_cm'], 13600.) + self.assertEqual(rc.lo_set_warnings(), []) + rc.set_lo_param('ebeam', '7 TeV') + self.assertAlmostEqual(rc['beam.e_cm'], 14000.) + # asymmetric beams: the same sqrt(s), and a warning at the end only + self.assertEqual(rc.set_lo_param('ebeam1', '7000')[1], []) + rc.set_lo_param('ebeam2', '4000') + self.assertAlmostEqual(rc['beam.e_cm'], 2 * (7000. * 4000.) ** 0.5) + warnings = rc.lo_set_warnings() + self.assertEqual(len(warnings), 1) + self.assertIn('ebeam1 = 7000 GeV and ebeam2 = 4000 GeV', warnings[0]) + self.assertEqual(rc.lo_set_warnings(), []) # said once + + rc.set_lo_param('lpp1', '0') + rc.set_lo_param('lpp2', '0') + self.assertIs(rc['beam.leptonic'], True) + self.assertEqual(rc.lo_set_warnings(), []) + rc.set_lo_param('lpp1', '1') + self.assertIs(rc['beam.leptonic'], False) + self.assertIn('lpp1 = 1 and lpp2 = 0', rc.lo_set_warnings()[0]) + self.assertIn('initial-state radiation', rc.set_lo_param('lpp1', '3')[1][0]) + self.assertIn('lpp1 = 3 and lpp2 = 1', rc.lo_set_warnings()[0]) + + rc.set_lo_param('pdlabel1', 'nn23lo') + rc.set_lo_param('pdlabel2', 'cteq6l1') + self.assertEqual(rc['beam.pdf'], 'cteq6l1') + self.assertIn('pdlabel1 = nn23lo and pdlabel2 = cteq6l1', + rc.lo_set_warnings()[0]) + # lhaid then pdlabel lhapdf, as some scripts write them + rc.set_lo_param('lhaid', '230000') + self.assertEqual(rc['beam.pdf'], 'NNPDF23_lo_as_0130_qed') + rc.lo_set_warnings() + + rc.set_lo_param('fixed_fac_scale1', 'T') + self.assertIs(rc['beam.fixed_fact_scale'], True) + self.assertIn('fixed_fac_scale1 = True and fixed_fac_scale2 = False', + rc.lo_set_warnings()[0]) + + def test_set_lo_param_pdg_cuts(self): + """{pdg: value} cuts as written in a run_card.dat""" + rc = bannermod.RunCardMG7() + rc.set_lo_param('pt_min_pdg', '{6: 100, 25: 50}') + self.assertEqual(rc['cuts']['pdg6-pt'], {'min': 100.0}) + self.assertEqual(rc['cuts']['pdg25-pt'], {'min': 50.0}) + self.assertEqual(rc['multiparticles']['pdg6'], [6, -6]) + rc.set_lo_param('eta_max_pdg', '{6: 2.5}') + self.assertEqual(rc['cuts']['pdg6-eta_abs'], {'max': 2.5}) + changes, _ = rc.set_lo_param('pt_min_pdg', '{6: 0}') + self.assertEqual(changes, {'cuts.pdg6-pt': None}) + self.assertNotIn('pdg6-pt', rc['cuts']) + # X X~ pairs only, whichever of the two is set first + rc.set_lo_param('mxx_only_part_antipart', '{6: True}') + rc.set_lo_param('mxx_min_pdg', '{6: 500}') + self.assertEqual(rc['cuts']['pdg6-sfos_pair_mass'], {'min': 500.0}) + rc.set_lo_param('mxx_only_part_antipart', "{'default': False, 6: False}") + self.assertNotIn('pdg6-sfos_pair_mass', rc['cuts']) + self.assertEqual(rc['cuts']['pdg6-pair_mass'], {'min': 500.0}) + self.assertRaises(bannermod.InvalidCmd, rc.set_lo_param, 'pt_min_pdg', '{6: abc}') + self.assertRaises(bannermod.InvalidCmd, rc.set_lo_param, 'pt_min_pdg', '100') + + def test_set_lo_param_matches_from_LO(self): + """"set" lands where the run_card.dat conversion puts the same values""" + lo = bannermod.RunCardLO() + values = {'ebeam1': 6800., 'ebeam2': 6800., 'dynamical_scale_choice': 1, + 'sde_strategy': 2, 'maxjetflavor': 5, 'fixed_fac_scale': True, + 'pdlabel': 'nn23lo', 'lpp1': 1, 'lpp2': 1} + for name, value in values.items(): + lo.set(name, value, user=True) + converted, _ = bannermod.RunCardMG7.from_LO(lo, warn=False) + rc = bannermod.RunCardMG7() + for name, value in values.items(): + self.assertIsNotNone(rc.set_lo_param(name, str(value)), name) + for key in ('beam.e_cm', 'beam.dynamical_scale_choice', 'beam.leptonic', + 'beam.fixed_fact_scale', 'beam.pdf', 'phasespace.sde_strategy'): + self.assertEqual(rc[key], converted[key], key) + self.assertEqual(rc['multiparticles']['jet'], converted['multiparticles']['jet']) + self.assertEqual(rc.lo_set_warnings(), []) + def test_evaluate_math_and_masses(self): """values support arithmetic and mass references""" rc = bannermod.RunCardMG7() From 40f46c8ead342b8364b57f8cdfa9b9ea797ab537 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 13 Oct 2026 23:20:52 +0200 Subject: [PATCH 3/3] from_LO: convert the per-pdg cuts of a run_card.dat file A run_card.dat is read with string pdg keys (pt_min_pdg = {6: 100} gives {'6': 100.0}), but from_LO only took integer keys, so the per-pdg cuts (pt/e/eta_min/max_pdg, mxx_min_pdg with mxx_only_part_antipart) of a real card were dropped without a report. The existing test set the dicts with integer keys directly, which hid it. _lo_pdg_dict turns the keys into integers (keeping 'default') before from_LO reads them. --- madgraph/various/banner.py | 24 +++++++++++++++++++----- tests/unit_tests/various/test_banner.py | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index b2d9e8068f..b9ec225ca4 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -7352,6 +7352,19 @@ def _lo_pdg_group(pdg): """Name and content of the group a madevent per-pdg cut is put on.""" return 'pdg%d' % abs(pdg), [abs(pdg), -abs(pdg)] + @staticmethod + def _lo_pdg_dict(mapping): + """A madevent {pdg: value} parameter with integer pdg keys: a + run_card.dat is read with string keys ({'6': 100.0}). 'default' is + kept as such.""" + out = {} + for key, val in (mapping or {}).items(): + if str(key).strip().lower() == 'default': + out['default'] = val + elif re.match(r'^\s*[-+]?\d+\s*$', str(key)): + out[int(key)] = val + return out + @staticmethod def _lo_pair_observable(pdg, only_part_antipart): """Observable of mxx_min_pdg for this pdg: X X~ pairs only when @@ -8472,15 +8485,16 @@ def add_cut(cutkey, bound, val): # cuts on one particle type, given by pdg id: a group [pdg, -pdg] each # (the name has no "_", which would select the n-th hardest) for loname, (observable, bound) in cls._LO_PDG_CUT_MAP.items(): - for pdg, val in lo[loname].items() if loname in lo else []: - if isinstance(pdg, int) and is_active(bound, val): + for pdg, val in cls._lo_pdg_dict(lo[loname] if loname in lo else {}).items(): + if pdg != 'default' and is_active(bound, val): group, pids = cls._lo_pdg_group(pdg) multiparticles[group] = pids add_cut('%s-%s' % (group, observable), bound, val) if 'mxx_min_pdg' in lo: - only_pairs = lo['mxx_only_part_antipart'] if 'mxx_only_part_antipart' in lo else {} - for pdg, val in lo['mxx_min_pdg'].items(): - if isinstance(pdg, int) and is_active('min', val): + only_pairs = cls._lo_pdg_dict( + lo['mxx_only_part_antipart'] if 'mxx_only_part_antipart' in lo else {}) + for pdg, val in cls._lo_pdg_dict(lo['mxx_min_pdg']).items(): + if pdg != 'default' and is_active('min', val): group, pids = cls._lo_pdg_group(pdg) multiparticles[group] = pids # X X~ pairs only, or every pair of the group diff --git a/tests/unit_tests/various/test_banner.py b/tests/unit_tests/various/test_banner.py index 15e73d3506..d698517ede 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -1965,6 +1965,25 @@ def test_from_LO_conversion_of_composite_cuts(self): self.assertNotIn('alllepton', plain['multiparticles']) self.assertNotIn('parton', plain['multiparticles']) + def test_from_LO_pdg_cuts_read_from_a_run_card(self): + """the per-pdg cuts of a run_card.dat file, which is read with string + pdg keys ({'6': 100.0}), are converted too""" + lo = bannermod.RunCardLO() + lo.set('pt_min_pdg', '{6: 100}', user=True) + lo.set('mxx_min_pdg', '{6: 250, 25: 100}', user=True) + lo.set('mxx_only_part_antipart', '{6: True}', user=True) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, 'run_card.dat') + lo.write(path) + lo = bannermod.RunCardLO(path) + self.assertEqual(lo['pt_min_pdg'], {'6': 100.0}) + mg7, _ = bannermod.RunCardMG7.from_LO(lo, warn=False) + cuts = mg7['cuts'] + self.assertEqual(cuts['pdg6-pt'], {'min': 100.0}) + self.assertEqual(cuts['pdg6-sfos_pair_mass'], {'min': 250.0}) + self.assertEqual(cuts['pdg25-pair_mass'], {'min': 100.0}) + self.assertEqual(mg7['multiparticles']['pdg25'], [25, -25]) + def test_from_LO_me_frame(self): """me_frame is carried over, the LO default included: madevent's [1,2] is the partonic c.m., mg7's own default [] the lab frame"""