diff --git a/docs/source/madevent_to_mg7.rst b/docs/source/madevent_to_mg7.rst index 7419a4f459..9bfee8a4ec 100644 --- a/docs/source/madevent_to_mg7.rst +++ b/docs/source/madevent_to_mg7.rst @@ -17,6 +17,24 @@ 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. 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. Beams and PDFs diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 4c10b71704..458e6499f6 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -3523,6 +3523,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 @@ -3589,6 +3598,55 @@ 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 + + # 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 7619d334b8..8e0dfde2b4 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -7299,6 +7299,297 @@ 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 = 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 + + 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 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_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 + 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) # ------------------------------------------------------------------ @@ -7968,6 +8259,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'), @@ -8030,6 +8324,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', @@ -8167,9 +8470,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() @@ -8183,9 +8485,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]): @@ -8194,20 +8496,20 @@ def is_active(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): - group = 'pdg%d' % abs(pdg) - multiparticles[group] = [abs(pdg), -abs(pdg)] + 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): - group = 'pdg%d' % abs(pdg) - multiparticles[group] = [abs(pdg), -abs(pdg)] + 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 - 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 32b80206d9..547ab21a91 100644 --- a/tests/unit_tests/interface/test_mg7_launch.py +++ b/tests/unit_tests/interface/test_mg7_launch.py @@ -688,6 +688,87 @@ 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_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 0e8e6d9f28..d6ae7d6a6f 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -1596,6 +1596,192 @@ 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_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() @@ -1779,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"""