From 1559debd2e8230725a80dff71b1955374dbc5fc6 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 1 Jul 2026 02:39:11 +0200 Subject: [PATCH 01/12] Add a RunCard-style Python representation for the mg7 run_card.toml Introduce banner.RunCardMG7, a typed representation of the TOML run_card used by the default (mg7/madnis) generation mode, built on the existing ConfigFile/RunCard machinery (typed params, allowed values, comments, user_set tracking, set()). - Section-aware flat storage with nested card["section"]["key"] access; free-form [multiparticles]/[cuts]/[histograms] kept as nested dicts. - read()/write() overridden for TOML; write() renders from a placeholder template (template_files/mg7/run_card.toml, now converted to %(section.key)s placeholders + $multiparticles/$cuts/$histograms markers). - create_default_for_process() fills process-dependent defaults (beam, multiparticles, cuts), then reads input/default_run_card_mg7.toml, mirroring the LO run_card logic. New hidden site-default input/.default_run_card_mg7.toml is copied at config time. - RunCard.__new__ dispatches TOML input to RunCardMG7. - The mg7 exporter (export_cpp.ProcessExporterMG7 / madmatrix) now generates Cards/run_card.toml (and run_card_default.toml) from the template with those defaults instead of copying the file verbatim. - The runtime (madevent.py load_cards, gridpack.py) consumes the class. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 + input/.default_run_card_mg7.toml | 21 + madgraph/interface/madgraph_interface.py | 1 + madgraph/iolibs/export_cpp.py | 43 +- .../iolibs/template_files/mg7/gridpack.py | 13 +- .../iolibs/template_files/mg7/madevent.py | 33 +- .../iolibs/template_files/mg7/run_card.toml | 195 +++--- madgraph/various/banner.py | 635 +++++++++++++++++- madmatrix/output.py | 4 +- tests/unit_tests/various/test_banner.py | 168 +++++ 10 files changed, 974 insertions(+), 141 deletions(-) create mode 100644 input/.default_run_card_mg7.toml diff --git a/.gitignore b/.gitignore index 77e9bcdcb..e601b6422 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ p_ME_* tests/status input/default_run_card_lo.dat input/default_run_card_nlo.dat +input/default_run_card_mg7.toml input/mg5_configuration.txt TEST_MW_* .vscode/ @@ -62,6 +63,7 @@ additional_command input/mg5_configuration.txt input/default_run_card_lo.dat input/default_run_card_nlo.dat +input/default_run_card_mg7.toml models/*/*.pkl py.py vendor/CutTools/includects/ diff --git a/input/.default_run_card_mg7.toml b/input/.default_run_card_mg7.toml new file mode 100644 index 000000000..4fa364909 --- /dev/null +++ b/input/.default_run_card_mg7.toml @@ -0,0 +1,21 @@ +# ********************************************************************* +# MadGraph5_aMC@NLO +# +# run_card.toml (mg7 / madnis) +# +# This file allows to overwrite the default values written in +# Cards/run_card.toml when it is generated for a dedicated process +# (i.e. when the "output" command is used). +# When you run the code, the value in Cards/run_card.toml is always used. +# +# It must be valid TOML: uncomment a "[section]" header together with +# the entries you want to change. +# ********************************************************************* +# +# Example (uncomment to apply): +# +# [generation] +# events = 50000 +# +# [phasespace] +# sde_strategy = "denominators" diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index 4c4c2c681..66a6478f2 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -7672,6 +7672,7 @@ def set_configuration(self, config_path=None, final=True): if not os.path.exists(pjoin(MG5DIR,'input','default_run_card_lo.dat')) and madgraph.ReadWrite: files.cp(pjoin(MG5DIR,'input','.default_run_card_lo.dat'), pjoin(MG5DIR,'input','default_run_card_lo.dat')) files.cp(pjoin(MG5DIR,'input','.default_run_card_nlo.dat'), pjoin(MG5DIR,'input','default_run_card_nlo.dat')) + files.cp(pjoin(MG5DIR,'input','.default_run_card_mg7.toml'), pjoin(MG5DIR,'input','default_run_card_mg7.toml')) config_file = open(config_path) diff --git a/madgraph/iolibs/export_cpp.py b/madgraph/iolibs/export_cpp.py index c8dc91f81..c431da743 100755 --- a/madgraph/iolibs/export_cpp.py +++ b/madgraph/iolibs/export_cpp.py @@ -31,6 +31,7 @@ import madgraph.core.base_objects as base_objects import madgraph.core.color_algebra as color import madgraph.core.helas_objects as helas_objects +import madgraph.iolibs.group_subprocs as group_subprocs import madgraph.iolibs.drawing_eps as draw import madgraph.iolibs.drawing_svg as draw_svg import madgraph.iolibs.files as files @@ -3139,9 +3140,12 @@ class ProcessExporterMG7(ProcessExporterCPP): # 'check' driver) template_Sub_make = pjoin(_file_path, 'iolibs', 'template_files', 'Makefile_sa_cpp_sp_api') + # NB: Cards/run_card.toml is NOT copied verbatim here; it is generated in + # finalize() from the run_card.toml template via banner.RunCardMG7, so that + # process-dependent defaults are filled in (see create_run_card). from_template = {'src': [s+'read_slha.h', s+'read_slha.cc', s+'mg7/api.h'], 'SubProcesses': [s+'mg7/api.cpp'], - 'Cards': [s+'mg7/run_card.toml']} + 'Cards': []} #from_template_simd = [ # s+"mg7/api.h", # s+"mg7/simd/api_simd.cpp", @@ -3213,15 +3217,50 @@ def copy_template(self, model): ) os.chmod(madnis_bin, 0o755) - def finalize(self, *args, **kwargs): + def finalize(self, matrix_elements=None, history='', *args, **kwargs): file_name = os.path.normpath(os.path.join( self.dir_path, "SubProcesses", "subprocesses.json" )) with open(file_name, 'w') as f: json.dump(self.process_info, f) + + # Generate Cards/run_card.toml from the template, filling in + # process-dependent defaults (mirrors the LO run_card.dat logic). + self.create_run_card(matrix_elements, history) + # we don't call super().finalize() since it would call ProcessExporterCPP.finalize() # which would compile the model in src/, and we don't want that + def create_run_card(self, matrix_elements, history): + """Write Cards/run_card.toml from the run_card.toml template via + banner.RunCardMG7, applying process-dependent defaults.""" + + run_card = banner_mod.RunCardMG7() + + processes = None + try: + if isinstance(matrix_elements, group_subprocs.SubProcessGroupList): + processes = [me.get('processes') for megroup in matrix_elements + for me in megroup['matrix_elements']] + elif matrix_elements: + processes = [me.get('processes') + for me in matrix_elements['matrix_elements']] + except (KeyError, TypeError): + processes = None + + if processes: + run_card.create_default_for_process(self.proc_characteristic, + history, processes) + + template = pjoin(_file_path, 'iolibs', 'template_files', + 'mg7', 'run_card.toml') + run_card.write(pjoin(self.dir_path, 'Cards', 'run_card.toml'), + template=template) + # Also write a concrete default card so the interactive card editor + # can offer "set default" (mirrors run_card_default.dat at LO). + run_card.write(pjoin(self.dir_path, 'Cards', 'run_card_default.toml'), + template=template) + def ExportCPPFactory(cmd, group_subprocesses=False, cmd_options={}): """ Determine which Export class is required. cmd is the command interface containing all potential usefull information. diff --git a/madgraph/iolibs/template_files/mg7/gridpack.py b/madgraph/iolibs/template_files/mg7/gridpack.py index 87b0ae378..165f1d16c 100644 --- a/madgraph/iolibs/template_files/mg7/gridpack.py +++ b/madgraph/iolibs/template_files/mg7/gridpack.py @@ -8,9 +8,16 @@ import argparse def main() -> None: - # load run card and metadata - with open(os.path.join("Cards", "run_card.toml"), "rb") as f: - run_card = tomllib.load(f) + # load run card and metadata. Use the RunCardMG7 representation when the + # madgraph package is importable; gridpacks are meant to be portable, so + # fall back to a plain tomllib parse otherwise (the card is the same TOML). + run_card_path = os.path.join("Cards", "run_card.toml") + try: + from madgraph.various.banner import RunCardMG7 + run_card = RunCardMG7(run_card_path) + except ImportError: + with open(run_card_path, "rb") as f: + run_card = tomllib.load(f) run_args = run_card["run"] gen_args = run_card["generation"] param_card_path = os.path.join("Cards", "param_card.dat") diff --git a/madgraph/iolibs/template_files/mg7/madevent.py b/madgraph/iolibs/template_files/mg7/madevent.py index 4781724d6..6cc70bf39 100644 --- a/madgraph/iolibs/template_files/mg7/madevent.py +++ b/madgraph/iolibs/template_files/mg7/madevent.py @@ -49,6 +49,7 @@ import madspace as ms from models.check_param_card import ParamCard +from madgraph.various.banner import RunCardMG7 from madgraph.various import misc logger = logging.getLogger("madevent7") @@ -131,8 +132,7 @@ def __init__(self): self.init_subprocesses() def load_cards(self) -> None: - with open(os.path.join("Cards", "run_card.toml"), "rb") as f: - self.run_card = tomllib.load(f) + self.run_card = RunCardMG7(os.path.join("Cards", "run_card.toml")) self.param_card_path = os.path.join("Cards", "param_card.dat") self.param_card = ParamCard(self.param_card_path) with open(os.path.join("SubProcesses", "subprocesses.json")) as f: @@ -720,32 +720,9 @@ def save_gridpack(self) -> None: cards_path = os.path.join(gridpack_path, "Cards") os.mkdir(cards_path) shutil.copy(os.path.join("Cards", "param_card.dat"), cards_path) - device_list = ",".join(f'"{device}"' for device in self.run_card["run"]["devices"]) - with open(os.path.join(cards_path, "run_card.toml"), "w") as f: - f.write(f"""[run] -run_name = "{self.run_card["run"]["run_name"]}" -devices = [{device_list}] # options: cpu, cuda -# options: -# -1 to choose automatically -# on x86: 1, 4, 8 -# on Apple silicon: 1, 2 -simd_vector_size = {self.run_card["run"]["simd_vector_size"]} -# pool sizes: -1 sets count automatically based on number of CPUs -cpu_thread_pool_size = {self.run_card["run"]["cpu_thread_pool_size"]} -gpu_thread_pool_size = {self.run_card["run"]["gpu_thread_pool_size"]} -combine_thread_pool_size = {self.run_card["run"]["combine_thread_pool_size"]} -output_format = "{self.run_card["run"]["output_format"]}" # options: compact_npy, lhe_npy, lhe -verbosity = "{self.run_card["run"]["verbosity"]}" # options: silent, pretty, log - -[generation] -events = {self.run_card["generation"]["events"]} -max_overweight_truncation = {self.run_card["generation"]["max_overweight_truncation"]} -freeze_max_weight_after = {self.run_card["generation"]["freeze_max_weight_after"]} -cpu_batch_size = {self.run_card["generation"]["cpu_batch_size"]} -gpu_batch_size = {self.run_card["generation"]["gpu_batch_size"]} -cut_efficiency_threshold = {self.run_card["generation"]["cut_efficiency_threshold"]} -max_cut_repetitions = {self.run_card["generation"]["max_cut_repetitions"]} -""") + # Re-emit the full run_card from its Python representation so the + # gridpack copy stays in sync with every parameter (and its template). + self.run_card.write(os.path.join(cards_path, "run_card.toml")) bin_path = os.path.join(gridpack_path, "bin") os.mkdir(bin_path) diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 83af64539..125b3407b 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -1,141 +1,126 @@ [run] -run_name = "run" +run_name = %(run.run_name)s # options: cuda, hip, cpp, cppnone, cppsse4, cppavx2, cpp512y, cpp512z, cppauto -devices = ["cppnone"] +devices = %(run.devices)s # options: # -1 to choose automatically # on x86: 1, 4, 8 # on Apple silicon: 1, 2 -simd_vector_size = -1 +simd_vector_size = %(run.simd_vector_size)s # pool sizes: -1 sets count automatically based on number of CPUs -cpu_thread_pool_size = -1 -gpu_thread_pool_size = 1 -combine_thread_pool_size = -1 -output_format = "compact_npy" # options: compact_npy, lhe_npy, lhe -verbosity = "pretty" # options: silent, pretty, log -dummy_matrix_element = false -save_gridpack = false -gridpack_include_source = false +cpu_thread_pool_size = %(run.cpu_thread_pool_size)s +gpu_thread_pool_size = %(run.gpu_thread_pool_size)s +combine_thread_pool_size = %(run.combine_thread_pool_size)s +output_format = %(run.output_format)s # options: compact_npy, lhe_npy, lhe +verbosity = %(run.verbosity)s # options: silent, pretty, log +dummy_matrix_element = %(run.dummy_matrix_element)s +save_gridpack = %(run.save_gridpack)s +gridpack_include_source = %(run.gridpack_include_source)s [beam] -e_cm = 13000.0 -leptonic = false -pdf = "NNPDF23_lo_as_0130_qed" -fixed_ren_scale = true -fixed_fact_scale = true -ren_scale = 91.188 -fact_scale1 = 91.188 -fact_scale2 = 91.188 +e_cm = %(beam.e_cm)s +leptonic = %(beam.leptonic)s +pdf = %(beam.pdf)s +fixed_ren_scale = %(beam.fixed_ren_scale)s +fixed_fact_scale = %(beam.fixed_fact_scale)s +ren_scale = %(beam.ren_scale)s +fact_scale1 = %(beam.fact_scale1)s +fact_scale2 = %(beam.fact_scale2)s # options: transverse_energy, transverse_mass, half_transverse_mass, partonic_energy -dynamical_scale_choice = "half_transverse_mass" +dynamical_scale_choice = %(beam.dynamical_scale_choice)s [generation] -events = 100000 -max_overweight_truncation = 0.01 -freeze_max_weight_after = 10000 -cpu_batch_size = 1000 -gpu_batch_size = 64000 -survey_min_iters = 3 -survey_max_iters = 3 -survey_target_precision = 0.1 -cut_efficiency_threshold = 0.7 -max_cut_repetitions = 1000 -systematics = false +events = %(generation.events)s +max_overweight_truncation = %(generation.max_overweight_truncation)s +freeze_max_weight_after = %(generation.freeze_max_weight_after)s +cpu_batch_size = %(generation.cpu_batch_size)s +gpu_batch_size = %(generation.gpu_batch_size)s +survey_min_iters = %(generation.survey_min_iters)s +survey_max_iters = %(generation.survey_max_iters)s +survey_target_precision = %(generation.survey_target_precision)s +cut_efficiency_threshold = %(generation.cut_efficiency_threshold)s +max_cut_repetitions = %(generation.max_cut_repetitions)s +systematics = %(generation.systematics)s [vegas] -enable = true -bins = 64 -damping = 0.4 -optimization_patience = 5 -optimization_threshold = 0.9 -start_batch_size = 1000 -max_batch_size = 32000 +enable = %(vegas.enable)s +bins = %(vegas.bins)s +damping = %(vegas.damping)s +optimization_patience = %(vegas.optimization_patience)s +optimization_threshold = %(vegas.optimization_threshold)s +start_batch_size = %(vegas.start_batch_size)s +max_batch_size = %(vegas.max_batch_size)s [phasespace] -mode = "both" #options: multichannel, flat, both -sde_strategy = "diagrams" #options: diagrams, denominators -decays = "all" # options: all, massive, none -t_channel = "propagator" # options: propagator, rambo, chili -flat_mode = "rambo" # options: propagator, rambo, chili -simplified_channel_count = 10 -invariant_power = 0.7 -bw_cutoff = 15 +mode = %(phasespace.mode)s #options: multichannel, flat, both +sde_strategy = %(phasespace.sde_strategy)s #options: diagrams, denominators +decays = %(phasespace.decays)s # options: all, massive, none +t_channel = %(phasespace.t_channel)s # options: propagator, rambo, chili +flat_mode = %(phasespace.flat_mode)s # options: propagator, rambo, chili +simplified_channel_count = %(phasespace.simplified_channel_count)s +invariant_power = %(phasespace.invariant_power)s +bw_cutoff = %(phasespace.bw_cutoff)s [multiparticles] -jet = [1, 2, 3, 4, -1, -2, -3, -4, 21] -bottom = [-5, 5] -lepton = [11, 13, 15, -11, -13, -15] -missing = [12, 14, 16, -12, -14, -16] -photon = [22] +$multiparticles [cuts] # possible groups: jet, bottom, lepton, missing, photon # possible observables: pt, eta, dR, mass, sqrt_s # (mass is for pairs of particles from the same group, sqrt_s is for all outgoing particles) # for all cuts, min or max can be specified - -jet-pt.min = 20.0 -jet-eta_abs.max = 5.0 -jet-delta_r.min = 0.4 - -lepton-pt.min = 10.0 -lepton-eta_abs.max = 2.5 -lepton-delta_r.min = 0.4 - -jet-lepton-delta_r.min=0.4 - -sqrt_s.min = 0.0 +$cuts [histograms] - +$histograms [madnis] -enable = false +enable = %(madnis.enable)s # normalizing flow parameters -flow_hidden_dim = 64 -flow_layers = 3 -flow_spline_bins = 10 -flow_activation = "leaky_relu" # options: relu, leaky_relu, elu, gelu, sigmoid, softplus -flow_invert_spline = false +flow_hidden_dim = %(madnis.flow_hidden_dim)s +flow_layers = %(madnis.flow_layers)s +flow_spline_bins = %(madnis.flow_spline_bins)s +flow_activation = %(madnis.flow_activation)s # options: relu, leaky_relu, elu, gelu, sigmoid, softplus +flow_invert_spline = %(madnis.flow_invert_spline)s # discrete dimensions -discrete_hidden_dim = 64 -discrete_layers = 3 -discrete_activation = "leaky_relu" # options: relu, leaky_relu, elu, gelu, sigmoid, softplus +discrete_hidden_dim = %(madnis.discrete_hidden_dim)s +discrete_layers = %(madnis.discrete_layers)s +discrete_activation = %(madnis.discrete_activation)s # options: relu, leaky_relu, elu, gelu, sigmoid, softplus # channel weight network -cwnet_hidden_dim = 64 -cwnet_layers = 3 -cwnet_activation = "leaky_relu" # options: relu, leaky_relu, elu, gelu, sigmoid, softplus +cwnet_hidden_dim = %(madnis.cwnet_hidden_dim)s +cwnet_layers = %(madnis.cwnet_layers)s +cwnet_activation = %(madnis.cwnet_activation)s # options: relu, leaky_relu, elu, gelu, sigmoid, softplus # training parameters -loss = "stratified_variance" # options: stratified_variance, kl_divergence, rkl_divergence -train_batches = 1000 -log_interval = 100 -batch_size_offset = 512 -batch_size_per_channel = 128 -generator_target_size_factor = 32 -gpu_generator_batch_granularity = 1000 -lr = 1e-3 -lr_decay = 0.01 -lr_max = 3e-3 -lr_scheduler = "cosine" # options: none, cosine -adam_beta1 = 0.9 -adam_beta2 = 0.999 -adam_eps = 1e-8 -train_mcw = true -buffer_capacity = 0 -minimum_buffer_size = 50000 -buffered_steps = 0 -buffer_unweighting_quantile = 0.99 -uniform_channel_ratio = 0.1 -integration_history_length = 100 -max_stored_channel_weights = 100 -channel_dropping_threshold = 0.01 -channel_dropping_interval = 100 -drop_zero_integrands = true -batch_size_threshold = 0.5 -channel_grouping_mode = "uniform" # options: none, uniform, learned -fixed_cwnet_fraction = 0.33 -softclip_threshold = 30.0 +loss = %(madnis.loss)s # options: stratified_variance, kl_divergence, rkl_divergence +train_batches = %(madnis.train_batches)s +log_interval = %(madnis.log_interval)s +batch_size_offset = %(madnis.batch_size_offset)s +batch_size_per_channel = %(madnis.batch_size_per_channel)s +generator_target_size_factor = %(madnis.generator_target_size_factor)s +gpu_generator_batch_granularity = %(madnis.gpu_generator_batch_granularity)s +lr = %(madnis.lr)s +lr_decay = %(madnis.lr_decay)s +lr_max = %(madnis.lr_max)s +lr_scheduler = %(madnis.lr_scheduler)s # options: none, cosine +adam_beta1 = %(madnis.adam_beta1)s +adam_beta2 = %(madnis.adam_beta2)s +adam_eps = %(madnis.adam_eps)s +train_mcw = %(madnis.train_mcw)s +buffer_capacity = %(madnis.buffer_capacity)s +minimum_buffer_size = %(madnis.minimum_buffer_size)s +buffered_steps = %(madnis.buffered_steps)s +buffer_unweighting_quantile = %(madnis.buffer_unweighting_quantile)s +uniform_channel_ratio = %(madnis.uniform_channel_ratio)s +integration_history_length = %(madnis.integration_history_length)s +max_stored_channel_weights = %(madnis.max_stored_channel_weights)s +channel_dropping_threshold = %(madnis.channel_dropping_threshold)s +channel_dropping_interval = %(madnis.channel_dropping_interval)s +drop_zero_integrands = %(madnis.drop_zero_integrands)s +batch_size_threshold = %(madnis.batch_size_threshold)s +channel_grouping_mode = %(madnis.channel_grouping_mode)s # options: none, uniform, learned +fixed_cwnet_fraction = %(madnis.fixed_cwnet_fraction)s +softclip_threshold = %(madnis.softclip_threshold)s diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 3c190f886..fd308f590 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -2858,7 +2858,10 @@ def __new__(cls, finput=None, **opt): path = finput if '\n' not in finput: finput = open(finput).read() - if 'req_acc_FO' in finput: + if path.endswith('.toml') or \ + re.search(r'(?m)^\s*\[(?:run|beam|generation|vegas|phasespace|madnis)\]\s*$', finput): + target_class = RunCardMG7 + elif 'req_acc_FO' in finput: target_class = RunCardNLO else: target_class = RunCardLO @@ -6295,7 +6298,635 @@ def create_default_for_process(self, proc_characteristic, history, proc_def): # This has to be LAST !! if os.path.exists(self.default_run_card): self.read(self.default_run_card, consistency=False) - + + +class TOMLSectionView(object): + """A light read/edit view on one section of a RunCardMG7. + + It lets the rest of the code keep the nested ``card["section"]["key"]`` + idiom (as produced by ``tomllib.load``) while the actual values live in the + flat ``RunCardMG7`` storage (keyed ``"
."``). Values are read + live, so the view always reflects the current state of the card.""" + + def __init__(self, card, section): + self.card = card + self.section = section + + def _key(self, name): + return '%s.%s' % (self.section, name.lower()) + + def __getitem__(self, name): + return self.card[self._key(name)] + + def __setitem__(self, name, value): + self.card[self._key(name)] = value + + def __contains__(self, name): + return self._key(name) in self.card + + def get(self, name, default=None): + key = self._key(name) + if key in self.card: + return self.card[key] + return default + + def __iter__(self): + return iter(self.card.toml_sections[self.section]) + + def keys(self): + return list(self.card.toml_sections[self.section]) + + def items(self): + return [(name, self[name]) for name in self] + + def values(self): + return [self[name] for name in self] + + def __len__(self): + return len(self.card.toml_sections[self.section]) + + def __repr__(self): + return repr(dict(self.items())) + + +class RunCardMG7(RunCard): + """Python representation of the TOML ``run_card.toml`` used by the default + (mg7/madnis) generation mode. + + It reuses the :class:`ConfigFile`/:class:`RunCard` machinery (typed + parameters, allowed values, comments, ``user_set`` tracking, ``set``) so + that the card can be edited programmatically (and, later, via a ``set`` + command or a card editor) exactly like the legacy ``run_card.dat``. + + TOML specifics handled here: + + - Section-aware flat storage: each fixed parameter is stored under the + unique key ``"
."`` (TOML keys are only unique within a + section, e.g. ``enable`` exists in both ``[vegas]`` and ``[madnis]``). + - Nested ``card["section"]["key"]`` access via :class:`TOMLSectionView`. + - Dynamic, user-extensible sections (``[multiparticles]``, ``[cuts]``, + ``[histograms]``) are kept as plain nested dicts in ``dynamic_sections``. + - ``read``/``write`` are overridden to parse/emit TOML (the base class is + line oriented ``value = name``). + """ + + LO = True + filename = 'run_card' + + # dynamic (free-form) sections, stored as nested dicts rather than as + # fixed typed parameters + dynamic_section_names = ('multiparticles', 'cuts', 'histograms') + + # energy units, expressed in GeV (the unit everything is stored in) + energy_units = {'ev': 1e-9, 'kev': 1e-6, 'mev': 1e-3, + 'gev': 1.0, 'tev': 1e3, 'pev': 1e6} + # typed parameters carrying an energy dimension (stored in GeV); a value + # like "13 TeV" is converted to 13000.0 when assigned to one of these. + energy_params = ('beam.e_cm', 'beam.ren_scale', + 'beam.fact_scale1', 'beam.fact_scale2') + # name -> pdg for masses that can be referenced in expressions (e.g. a scale + # set to "mz/2"); resolved against the param_card by get_mass_shortcuts. + mass_shortcuts = {'mz': 23, 'mw': 24, 'mh': 25, 'mt': 6, 'mb': 5, + 'mc': 4, 'mtau': 15, 'mta': 15, 'mmu': 13} + + if MG5DIR: + template_run_card = pjoin(MG5DIR, 'madgraph', 'iolibs', + 'template_files', 'mg7', 'run_card.toml') + default_run_card = pjoin(MG5DIR, "internal", "default_run_card_mg7.toml") + else: + template_run_card = None + default_run_card = None + + def __init__(self, *args, **opts): + # ordered {section: [shortkey, ...]} for the fixed (typed) parameters + self.toml_sections = collections.OrderedDict() + # internal_key -> section + self.section_of = {} + # free-form sections kept as nested dicts + self.dynamic_sections = collections.OrderedDict() + # unknown sections preserved for round-trip + self.extra_sections = collections.OrderedDict() + super(RunCardMG7, self).__init__(*args, **opts) + + # ------------------------------------------------------------------ + # parameter declaration + # ------------------------------------------------------------------ + def add_toml_param(self, section, key, value, **opts): + """Declare one fixed (typed) TOML parameter belonging to ``section``.""" + section = section.lower() + key = key.lower() + internal = '%s.%s' % (section, key) + # not a fortran parameter: never goes to an include file + opts.setdefault('include', False) + self.add_param(internal, value, **opts) + self.section_of[internal] = section + self.toml_sections.setdefault(section, []) + if key not in self.toml_sections[section]: + self.toml_sections[section].append(key) + + def default_setup(self): + """Define every parameter of the default ``run_card.toml``.""" + + # ----------------------------- [run] -------------------------- + self.add_toml_param('run', 'run_name', "run") + self.add_toml_param('run', 'devices', ["cppnone"], typelist=str, + comment="options: cuda, hip, cpp, cppnone, cppsse4, cppavx2, cpp512y, cpp512z, cppauto") + self.add_toml_param('run', 'simd_vector_size', -1, + comment="-1 chooses automatically; on x86: 1, 4, 8; on Apple silicon: 1, 2") + self.add_toml_param('run', 'cpu_thread_pool_size', -1, + comment="-1 sets count automatically based on number of CPUs") + self.add_toml_param('run', 'gpu_thread_pool_size', 1) + self.add_toml_param('run', 'combine_thread_pool_size', -1) + self.add_toml_param('run', 'output_format', "compact_npy", + allowed=['compact_npy', 'lhe_npy', 'lhe']) + self.add_toml_param('run', 'verbosity', "pretty", + allowed=['silent', 'pretty', 'log']) + self.add_toml_param('run', 'dummy_matrix_element', False) + self.add_toml_param('run', 'save_gridpack', False) + self.add_toml_param('run', 'gridpack_include_source', False) + + # ----------------------------- [beam] ------------------------- + self.add_toml_param('beam', 'e_cm', 13000.0) + self.add_toml_param('beam', 'leptonic', False) + self.add_toml_param('beam', 'pdf', "NNPDF23_lo_as_0130_qed") + self.add_toml_param('beam', 'fixed_ren_scale', True) + self.add_toml_param('beam', 'fixed_fact_scale', True) + self.add_toml_param('beam', 'ren_scale', 91.188) + self.add_toml_param('beam', 'fact_scale1', 91.188) + self.add_toml_param('beam', 'fact_scale2', 91.188) + self.add_toml_param('beam', 'dynamical_scale_choice', "half_transverse_mass", + allowed=['transverse_energy', 'transverse_mass', + 'half_transverse_mass', 'partonic_energy']) + + # -------------------------- [generation] ---------------------- + self.add_toml_param('generation', 'events', 100000) + self.add_toml_param('generation', 'max_overweight_truncation', 0.01) + self.add_toml_param('generation', 'freeze_max_weight_after', 10000) + self.add_toml_param('generation', 'cpu_batch_size', 1000) + self.add_toml_param('generation', 'gpu_batch_size', 64000) + self.add_toml_param('generation', 'survey_min_iters', 3) + self.add_toml_param('generation', 'survey_max_iters', 3) + self.add_toml_param('generation', 'survey_target_precision', 0.1) + self.add_toml_param('generation', 'cut_efficiency_threshold', 0.7) + self.add_toml_param('generation', 'max_cut_repetitions', 1000) + self.add_toml_param('generation', 'systematics', False) + + # ----------------------------- [vegas] ------------------------ + self.add_toml_param('vegas', 'enable', True) + self.add_toml_param('vegas', 'bins', 64) + self.add_toml_param('vegas', 'damping', 0.4) + self.add_toml_param('vegas', 'optimization_patience', 5) + self.add_toml_param('vegas', 'optimization_threshold', 0.9) + self.add_toml_param('vegas', 'start_batch_size', 1000) + self.add_toml_param('vegas', 'max_batch_size', 32000) + + # -------------------------- [phasespace] ---------------------- + self.add_toml_param('phasespace', 'mode', "both", + allowed=['multichannel', 'flat', 'both']) + self.add_toml_param('phasespace', 'sde_strategy', "diagrams", + allowed=['diagrams', 'denominators']) + self.add_toml_param('phasespace', 'decays', "all", + allowed=['all', 'massive', 'none']) + self.add_toml_param('phasespace', 't_channel', "propagator", + allowed=['propagator', 'rambo', 'chili']) + self.add_toml_param('phasespace', 'flat_mode', "rambo", + allowed=['propagator', 'rambo', 'chili']) + self.add_toml_param('phasespace', 'simplified_channel_count', 10) + self.add_toml_param('phasespace', 'invariant_power', 0.7) + self.add_toml_param('phasespace', 'bw_cutoff', 15) + + # ----------------------------- [madnis] ----------------------- + self.add_toml_param('madnis', 'enable', False) + self.add_toml_param('madnis', 'flow_hidden_dim', 64) + self.add_toml_param('madnis', 'flow_layers', 3) + self.add_toml_param('madnis', 'flow_spline_bins', 10) + self.add_toml_param('madnis', 'flow_activation', "leaky_relu", + allowed=['relu', 'leaky_relu', 'elu', 'gelu', 'sigmoid', 'softplus']) + self.add_toml_param('madnis', 'flow_invert_spline', False) + self.add_toml_param('madnis', 'discrete_hidden_dim', 64) + self.add_toml_param('madnis', 'discrete_layers', 3) + self.add_toml_param('madnis', 'discrete_activation', "leaky_relu", + allowed=['relu', 'leaky_relu', 'elu', 'gelu', 'sigmoid', 'softplus']) + self.add_toml_param('madnis', 'cwnet_hidden_dim', 64) + self.add_toml_param('madnis', 'cwnet_layers', 3) + self.add_toml_param('madnis', 'cwnet_activation', "leaky_relu", + allowed=['relu', 'leaky_relu', 'elu', 'gelu', 'sigmoid', 'softplus']) + self.add_toml_param('madnis', 'loss', "stratified_variance", + allowed=['stratified_variance', 'kl_divergence', 'rkl_divergence']) + self.add_toml_param('madnis', 'train_batches', 1000) + self.add_toml_param('madnis', 'log_interval', 100) + self.add_toml_param('madnis', 'batch_size_offset', 512) + self.add_toml_param('madnis', 'batch_size_per_channel', 128) + self.add_toml_param('madnis', 'generator_target_size_factor', 32) + self.add_toml_param('madnis', 'gpu_generator_batch_granularity', 1000) + self.add_toml_param('madnis', 'lr', 1e-3) + self.add_toml_param('madnis', 'lr_decay', 0.01) + self.add_toml_param('madnis', 'lr_max', 3e-3) + self.add_toml_param('madnis', 'lr_scheduler', "cosine", + allowed=['none', 'cosine']) + self.add_toml_param('madnis', 'adam_beta1', 0.9) + self.add_toml_param('madnis', 'adam_beta2', 0.999) + self.add_toml_param('madnis', 'adam_eps', 1e-8) + self.add_toml_param('madnis', 'train_mcw', True) + self.add_toml_param('madnis', 'buffer_capacity', 0) + self.add_toml_param('madnis', 'minimum_buffer_size', 50000) + self.add_toml_param('madnis', 'buffered_steps', 0) + self.add_toml_param('madnis', 'buffer_unweighting_quantile', 0.99) + self.add_toml_param('madnis', 'uniform_channel_ratio', 0.1) + self.add_toml_param('madnis', 'integration_history_length', 100) + self.add_toml_param('madnis', 'max_stored_channel_weights', 100) + self.add_toml_param('madnis', 'channel_dropping_threshold', 0.01) + self.add_toml_param('madnis', 'channel_dropping_interval', 100) + self.add_toml_param('madnis', 'drop_zero_integrands', True) + self.add_toml_param('madnis', 'batch_size_threshold', 0.5) + self.add_toml_param('madnis', 'channel_grouping_mode', "uniform", + allowed=['none', 'uniform', 'learned']) + self.add_toml_param('madnis', 'fixed_cwnet_fraction', 0.33) + self.add_toml_param('madnis', 'softclip_threshold', 30.0) + + # ----------------- dynamic (free-form) sections --------------- + self.dynamic_sections['multiparticles'] = collections.OrderedDict([ + ('jet', [1, 2, 3, 4, -1, -2, -3, -4, 21]), + ('bottom', [-5, 5]), + ('lepton', [11, 13, 15, -11, -13, -15]), + ('missing', [12, 14, 16, -12, -14, -16]), + ('photon', [22]), + ]) + self.dynamic_sections['cuts'] = collections.OrderedDict([ + ('jet-pt', {'min': 20.0}), + ('jet-eta_abs', {'max': 5.0}), + ('jet-delta_r', {'min': 0.4}), + ('lepton-pt', {'min': 10.0}), + ('lepton-eta_abs', {'max': 2.5}), + ('lepton-delta_r', {'min': 0.4}), + ('jet-lepton-delta_r', {'min': 0.4}), + ('sqrt_s', {'min': 0.0}), + ]) + self.dynamic_sections['histograms'] = collections.OrderedDict() + + # ------------------------------------------------------------------ + # access: nested section views + # ------------------------------------------------------------------ + def __getitem__(self, name): + if isinstance(name, str): + lname = name.lower() + if lname in self.dynamic_sections: + return self.dynamic_sections[lname] + if lname in self.toml_sections: + return TOMLSectionView(self, lname) + return super(RunCardMG7, self).__getitem__(name) + + get = __getitem__ + + # ------------------------------------------------------------------ + # reading TOML + # ------------------------------------------------------------------ + def read(self, finput, consistency=True, unknown_warning=True, **opt): + """Read a TOML run_card from a path, a file object or a string.""" + import tomllib + + self.path = None + if isinstance(finput, str): + if '\n' in finput: + text = finput + elif os.path.isfile(finput): + self.path = finput + with open(finput) as fsock: + text = fsock.read() + else: + raise Exception("No such file %s" % finput) + elif hasattr(finput, 'read'): + text = finput.read() + if isinstance(text, bytes): + text = text.decode() + else: + raise Exception("RunCardMG7 cannot read input of type %s" % type(finput)) + + data = tomllib.loads(text) + self.read_data(data, unknown_warning=unknown_warning) + + if consistency: + try: + self.check_validity() + except InvalidRunCard as error: + if consistency == 'warning': + logger.warning(str(error)) + else: + raise + + def read_data(self, data, unknown_warning=True): + """Fill the card from a parsed TOML mapping (section -> {key: value}).""" + for section, content in data.items(): + sl = section.lower() + if sl in self.dynamic_sections: + self.dynamic_sections[sl] = collections.OrderedDict(content) + elif sl in self.toml_sections: + for key, value in content.items(): + internal = '%s.%s' % (sl, key.lower()) + if internal in self: + self.set(internal, value, user=True) + else: + if unknown_warning: + logger.warning("Unexpected entry %s in section [%s] of run_card.toml", key, sl) + self.add_toml_param(sl, key.lower(), value) + self.set(internal, value, user=True) + else: + if unknown_warning: + logger.warning("Unexpected section [%s] in run_card.toml", sl) + self.extra_sections[sl] = collections.OrderedDict(content) + + # ------------------------------------------------------------------ + # value setting (energy units + cuts) + # ------------------------------------------------------------------ + @classmethod + def parse_energy(cls, value): + """Convert an energy string carrying a unit (eV/keV/MeV/GeV/TeV/PeV) + to GeV. ``"1 TeV"`` -> ``1000.0``, ``"500GeV"`` -> ``500.0``. + + If ``value`` is not a string, or has no (recognised) unit, it is + returned unchanged so that the normal numeric parsing (including the + ``k``/``M`` suffixes handled by :func:`ConfigFile.format_variable`) + still applies.""" + if not isinstance(value, str): + return value + m = re.match(r'^\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eEdD][-+]?\d+)?)\s*([a-zA-Z]+)\s*$', + value) + if not m: + return value + if m.group(2).lower() not in cls.energy_units: + return value + number = float(m.group(1).replace('d', 'e').replace('D', 'e')) + return number * cls.energy_units[m.group(2).lower()] + + def set(self, name, value, *args, **opts): + """Like :meth:`ConfigFile.set` but understands energy units for the + energy-dimensioned parameters (e.g. ``set beam.e_cm 13 TeV``).""" + if isinstance(name, str) and name.lower() in self.energy_params: + value = self.parse_energy(value) + return super(RunCardMG7, self).set(name, value, *args, **opts) + + def is_cut_name(self, name): + """True if ``name`` addresses a cut entry of the ``[cuts]`` section + (e.g. ``jet-pt.min``, ``cuts.jet-pt.max``).""" + if not isinstance(name, str): + return False + name = name.strip().lower() + if name.startswith('cuts.'): + return True + base = name.rsplit('.', 1)[0] if '.' in name else name + return base in self.dynamic_sections['cuts'] + + def set_cut(self, name, value): + """Set a value in the ``[cuts]`` section. ``name`` is of the form + ``.`` (bound = min/max/mode), optionally prefixed with + ``cuts.``. Numeric bounds accept energy units and are stored in GeV. + Returns ``(cut, bound, value)``.""" + name = name.strip().lower() + if name.startswith('cuts.'): + name = name[len('cuts.'):] + if '.' in name: + cut, bound = name.rsplit('.', 1) + else: + cut, bound = name, 'min' + if bound not in ('min', 'max', 'mode'): + cut, bound = name, 'min' + if bound == 'mode': + value = str(value).strip() + else: + value = self.parse_energy(value) + if isinstance(value, str): + value = self.format_variable(value, float, name=name) + self.dynamic_sections['cuts'].setdefault(cut, collections.OrderedDict())[bound] = value + return cut, bound, value + + # ------------------------------------------------------------------ + # expression evaluation (energy units + arithmetic + masses) + # ------------------------------------------------------------------ + @staticmethod + def _safe_eval(expr, names): + """Evaluate a pure-arithmetic expression (``+ - * / **`` and unary + signs) where identifiers are resolved from ``names``. No function + calls/attributes are allowed (so it is safe on untrusted input).""" + import ast, operator + ops = {ast.Add: operator.add, ast.Sub: operator.sub, + ast.Mult: operator.mul, ast.Div: operator.truediv, + ast.Pow: operator.pow, ast.USub: operator.neg, + ast.UAdd: operator.pos} + + def ev(node): + if isinstance(node, ast.Expression): + return ev(node.body) + if isinstance(node, ast.Constant): + if isinstance(node.value, bool) or not isinstance(node.value, (int, float)): + raise ValueError("not a number") + return node.value + if isinstance(node, ast.Name): + if node.id.lower() in names: + return names[node.id.lower()] + raise ValueError("unknown name %s" % node.id) + if isinstance(node, ast.BinOp) and type(node.op) in ops: + return ops[type(node.op)](ev(node.left), ev(node.right)) + if isinstance(node, ast.UnaryOp) and type(node.op) in ops: + return ops[type(node.op)](ev(node.operand)) + raise ValueError("unsupported expression") + + return ev(ast.parse(expr, mode='eval')) + + def evaluate(self, value, masses=None): + """Resolve a user-supplied value into a number when possible. + + Handles, in order: energy units (``13 TeV`` -> 13000), then arithmetic + expressions possibly referencing masses (``mz/2``, ``2*mh``). If the + value cannot be turned into a number it is returned unchanged, so plain + strings, ``k``/``M`` suffixes, ``default``/``auto`` etc. keep working.""" + if not isinstance(value, str): + return value + v = value.strip() + if not v: + return value + converted = self.parse_energy(v) + if not isinstance(converted, str): + return converted + try: + return float(self._safe_eval(v, masses or {})) + except Exception: + return value + + def get_mass_shortcuts(self, param_card): + """Build the {name: mass} mapping (mz, mh, ...) from a param_card.""" + out = {} + if not param_card: + return out + for name, pdg in self.mass_shortcuts.items(): + try: + out[name] = float(param_card.get_value('mass', pdg)) + except Exception: + continue + return out + + # ------------------------------------------------------------------ + # madevent-style shortcuts + # ------------------------------------------------------------------ + def remove_all_cut(self): + """Remove every cut (``set no_parton_cut`` / ``set nocut``).""" + self.dynamic_sections['cuts'] = collections.OrderedDict() + + def set_collider(self, name, arg, masses=None): + """Collider shortcuts: ``lhc``/``lcc`` (hadron, unit-less = TeV) and + ``lep``/``ilc`` (lepton, unit-less = GeV). Sets e_cm and leptonic.""" + name = name.lower() + had_unit = not isinstance(self.parse_energy(arg), str) + val = self.evaluate(arg, masses) + if isinstance(val, str): + val = self.format_variable(val, float, name=name) + if not had_unit and name in ('lhc', 'lcc'): + val = val * 1000.0 # unit-less hadron collider energy is in TeV + self.set('beam.e_cm', float(val), user=True) + self.set('beam.leptonic', name in ('lep', 'ilc'), user=True) + return float(val) + + def set_fixed_scale(self, value, masses=None): + """``set fixed_scale X``: switch to fixed scales and set them all to X + (X may use units, arithmetic or a mass reference, e.g. ``mz``).""" + val = self.evaluate(value, masses) + if isinstance(val, str): + val = self.format_variable(val, float, name='fixed_scale') + val = float(val) + self.set('beam.fixed_ren_scale', True, user=True) + self.set('beam.fixed_fact_scale', True, user=True) + self.set('beam.ren_scale', val, user=True) + self.set('beam.fact_scale1', val, user=True) + self.set('beam.fact_scale2', val, user=True) + return val + + def check_validity(self): + """Minimal consistency checks for the TOML run_card.""" + if self['generation']['survey_min_iters'] > self['generation']['survey_max_iters']: + raise InvalidRunCard("survey_min_iters can not be larger than survey_max_iters") + + # ------------------------------------------------------------------ + # writing TOML + # ------------------------------------------------------------------ + @staticmethod + def format_toml_value(value): + """Render a Python value as a TOML literal.""" + if isinstance(value, bool): + return 'true' if value else 'false' + elif isinstance(value, str): + return '"%s"' % value + elif isinstance(value, (list, tuple)): + return '[%s]' % ', '.join(RunCardMG7.format_toml_value(v) for v in value) + else: + return str(value) + + def format_dynamic_section(self, section): + """Render a free-form section (multiparticles/cuts/histograms).""" + lines = [] + for key, value in self.dynamic_sections.get(section, {}).items(): + if isinstance(value, dict): + # nested keys: e.g. jet-pt.min = 20.0 + for subkey, subval in value.items(): + lines.append('%s.%s = %s' % (key, subkey, + self.format_toml_value(subval))) + else: + lines.append('%s = %s' % (key, self.format_toml_value(value))) + return '\n'.join(lines) + + def write(self, output_file, template=None, python_template=True, **opt): + """Write a valid ``run_card.toml``. + + Values are rendered from the placeholder template (the one shipped in + template_files/mg7). ``template`` may be passed by the generic card + editor as a concrete default card (``run_card_default.toml``); since + that file has no ``%(...)s`` placeholders it cannot drive the + rendering, so in that case (or when it is missing) we fall back to the + shipped placeholder template.""" + text = None + if template and os.path.exists(template): + with open(template) as fsock: + text = fsock.read() + if '%(' not in text: + text = None # concrete card, not a placeholder template + if text is None: + if not self.template_run_card or not os.path.exists(self.template_run_card): + raise Exception("RunCardMG7.write requires a template (run_card.toml)") + with open(self.template_run_card) as fsock: + text = fsock.read() + + # fixed (typed) parameters: substitute %(section.key)s placeholders + data = {} + for section, keys in self.toml_sections.items(): + for key in keys: + internal = '%s.%s' % (section, key) + data[internal] = self.format_toml_value(self[internal]) + text = text % data + + # free-form sections inserted at their markers + for section in self.dynamic_section_names: + text = text.replace('$%s' % section, + self.format_dynamic_section(section)) + + # preserve any unknown section read from an existing card + for section, content in self.extra_sections.items(): + block = '\n[%s]\n' % section + for key, value in content.items(): + block += '%s = %s\n' % (key, self.format_toml_value(value)) + text += block + + if isinstance(output_file, str): + with open(output_file, 'w') as fsock: + fsock.write(text) + else: + output_file.write(text) + + # ------------------------------------------------------------------ + # process dependent defaults (mirrors RunCardLO.create_default_for_process) + # ------------------------------------------------------------------ + def create_default_for_process(self, proc_characteristic, history, proc_def): + """Adjust default values depending on the process, then read the + site/user default file (``input/default_run_card_mg7.toml``).""" + + # collect the initial-state (beam) particle ids + beam_id = set() + try: + for proc in proc_def: + for oneproc in proc: + for leg in oneproc['legs']: + if not leg['state']: + beam_id.add(leg['id']) + except (TypeError, KeyError, IndexError): + beam_id = set() + + # 81/-81 and 82/-82 are MadGraph composite beam codes (light-jet and + # lepton beams respectively); treat them like their concrete partners. + hadronic = set([1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 21, 22, 81, -81]) + leptons = set([11, -11, 13, -13, 15, -15, 82, -82]) + + if beam_id and beam_id <= leptons: + # lepton collider: no PDF, lower default energy, no hadronic cuts + self['beam']['leptonic'] = True + self['beam']['e_cm'] = 1000.0 + self['beam']['fixed_fact_scale'] = True + self.remove_jet_cuts() + elif beam_id and not (beam_id & hadronic): + # e.g. photon/neutrino initiated: also no proton PDF + self['beam']['leptonic'] = True + + # 1 -> N decay: no cuts at all + if proc_characteristic and proc_characteristic['ninitial'] == 1: + self.dynamic_sections['cuts'] = collections.OrderedDict() + + # site/user defaults win (this has to be LAST, like the LO run_card) + if self.default_run_card and os.path.exists(self.default_run_card): + self.read(self.default_run_card, consistency=False) + + def remove_jet_cuts(self): + """Drop jet related cuts (used for lepton colliders).""" + for key in list(self.dynamic_sections['cuts']): + if key.startswith('jet'): + del self.dynamic_sections['cuts'][key] + + class MadLoopParam(ConfigFile): """ a class for storing/dealing with the file MadLoopParam.dat contains a parser to read it, facilities to write a new file,... diff --git a/madmatrix/output.py b/madmatrix/output.py index 0fba8bb3e..3183f9e45 100644 --- a/madmatrix/output.py +++ b/madmatrix/output.py @@ -98,7 +98,9 @@ class ProcessExporterMadMatrix(export_cpp.ProcessExporterMG7): 'MatrixElementKernels.cc', 'MatrixElementKernels.h', 'EventStatistics.h', 'umami.h', 'umami.cc', 'rambo.h']), - 'Cards': relative_path_list(mg7_templates, ["run_card.toml"])} + # run_card.toml is generated in finalize() (ProcessExporterMG7.create_run_card) + # from the template, not copied verbatim. + 'Cards': []} to_link_in_P = ['nvtx.h', 'GpuRuntime.h', 'GpuAbstraction.h', 'color_sum.h', 'MemoryAccessHelpers.h', 'MemoryAccessVectors.h', diff --git a/tests/unit_tests/various/test_banner.py b/tests/unit_tests/various/test_banner.py index 4e95f0974..b9e7dfa35 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -1163,6 +1163,174 @@ def test_fixed_fac_scale_block(self): self.assertIn("True = fixed_fac_scale2", f.getvalue()) +class TestRunCardMG7(unittest.TestCase): + """Test the TOML run_card (RunCardMG7) used by the mg7/madnis mode.""" + + template = pjoin(MG5DIR, 'madgraph', 'iolibs', 'template_files', + 'mg7', 'run_card.toml') + + def test_runcard_dispatches_to_mg7_for_toml(self): + """banner.RunCard(...) returns a RunCardMG7 for TOML input""" + rc = bannermod.RunCardMG7() + out = io.StringIO() + rc.write(out, template=self.template) + content = out.getvalue() + # dispatch from string content + self.assertIsInstance(bannermod.RunCard(content), bannermod.RunCardMG7) + # dispatch from a .toml path + tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.toml', delete=False) + tmp.write(content) + tmp.close() + try: + self.assertIsInstance(bannermod.RunCard(tmp.name), bannermod.RunCardMG7) + finally: + os.remove(tmp.name) + + def test_numeric_suffix_like_legacy(self): + """integer/float k/M suffixes are parsed as in the legacy RunCard""" + rc = bannermod.RunCardMG7() + rc.set('generation.events', '10k', user=True) + self.assertEqual(rc['generation']['events'], 10000) + rc.set('generation.events', '2M', user=True) + self.assertEqual(rc['generation']['events'], 2000000) + + def test_energy_units(self): + """energy units are converted to GeV for energy parameters""" + rc = bannermod.RunCardMG7() + rc.set('beam.e_cm', '13 TeV', user=True) + self.assertEqual(rc['beam']['e_cm'], 13000.0) + rc.set('beam.ren_scale', '500GeV', user=True) + self.assertEqual(rc['beam']['ren_scale'], 500.0) + # parse_energy leaves unit-less / non-energy strings untouched + self.assertEqual(rc.parse_energy('30'), '30') + self.assertEqual(rc.parse_energy('1 TeV'), 1000.0) + self.assertAlmostEqual(rc.parse_energy('500 MeV'), 0.5) + + def test_set_cut_with_units(self): + """cut bounds are settable and converted to GeV; dimensionless ok""" + rc = bannermod.RunCardMG7() + rc.set_cut('jet-pt.min', '1 TeV') + self.assertEqual(rc['cuts']['jet-pt'], {'min': 1000.0}) + rc.set_cut('lepton-pt.min', '500 MeV') + self.assertAlmostEqual(rc['cuts']['lepton-pt']['min'], 0.5) + rc.set_cut('jet-eta_abs.max', '4') # dimensionless, no unit + self.assertEqual(rc['cuts']['jet-eta_abs'], {'max': 4.0}) + self.assertTrue(rc.is_cut_name('jet-pt.min')) + self.assertTrue(rc.is_cut_name('cuts.jet-pt.max')) + self.assertFalse(rc.is_cut_name('generation.events')) + + def test_evaluate_math_and_masses(self): + """values support arithmetic and mass references""" + rc = bannermod.RunCardMG7() + masses = {'mz': 91.0, 'mh': 125.0} + self.assertEqual(rc.evaluate('91.0*2', masses), 182.0) + self.assertEqual(rc.evaluate('mz/2', masses), 45.5) + self.assertEqual(rc.evaluate('2*mh', masses), 250.0) + self.assertEqual(rc.evaluate('mz+9', masses), 100.0) + self.assertEqual(rc.evaluate('1 TeV', masses), 1000.0) + # non-evaluable values are returned unchanged (k/M, strings, bools) + self.assertEqual(rc.evaluate('10k', masses), '10k') + self.assertEqual(rc.evaluate('default', masses), 'default') + self.assertEqual(rc.evaluate('True', masses), 'True') + + def test_collider_shortcuts(self): + """lhc/lep shortcuts set e_cm (GeV) and leptonic""" + rc = bannermod.RunCardMG7() + rc.set_collider('lhc', '13') # unit-less -> TeV + self.assertEqual(rc['beam']['e_cm'], 13000.0) + self.assertIs(rc['beam']['leptonic'], False) + rc.set_collider('lhc', '13 TeV') # explicit unit + self.assertEqual(rc['beam']['e_cm'], 13000.0) + rc.set_collider('lep', '250') # unit-less -> GeV + self.assertEqual(rc['beam']['e_cm'], 250.0) + self.assertIs(rc['beam']['leptonic'], True) + + def test_fixed_scale_and_remove_cuts(self): + """fixed_scale sets all scales; remove_all_cut clears cuts""" + rc = bannermod.RunCardMG7() + rc.set_fixed_scale('mz', {'mz': 91.0}) + self.assertIs(rc['beam']['fixed_ren_scale'], True) + self.assertIs(rc['beam']['fixed_fact_scale'], True) + self.assertEqual(rc['beam']['ren_scale'], 91.0) + self.assertEqual(rc['beam']['fact_scale1'], 91.0) + self.assertEqual(rc['beam']['fact_scale2'], 91.0) + self.assertTrue(rc['cuts']) + rc.remove_all_cut() + self.assertEqual(dict(rc['cuts']), {}) + + def test_defaults_and_section_access(self): + """default values are accessible through nested-section views""" + rc = bannermod.RunCardMG7() + # fixed (typed) parameters, via card["section"]["key"] + self.assertEqual(rc['generation']['events'], 100000) + self.assertEqual(rc['beam']['e_cm'], 13000.0) + self.assertIs(rc['vegas']['enable'], True) + self.assertIs(rc['madnis']['enable'], False) + self.assertEqual(rc['phasespace']['mode'], 'both') + # the "enable" key colliding between [vegas] and [madnis] is stored + # independently, so editing one does not affect the other + rc['madnis']['enable'] = True + self.assertIs(rc['vegas']['enable'], True) + self.assertIs(rc['madnis']['enable'], True) + # dynamic sections are plain nested dicts + self.assertEqual(rc['multiparticles']['jet'], + [1, 2, 3, 4, -1, -2, -3, -4, 21]) + self.assertEqual(rc['cuts']['jet-pt'], {'min': 20.0}) + self.assertEqual(rc['cuts'].get('order_by', 'pt'), 'pt') + + def test_write_is_valid_toml(self): + """write() produces valid TOML identical to the python defaults""" + import tomllib + rc = bannermod.RunCardMG7() + out = io.StringIO() + rc.write(out, template=self.template) + data = tomllib.loads(out.getvalue()) + self.assertEqual(data['run']['output_format'], 'compact_npy') + self.assertEqual(data['beam']['e_cm'], 13000.0) + self.assertIs(data['vegas']['enable'], True) + self.assertEqual(data['multiparticles']['photon'], [22]) + self.assertEqual(data['cuts']['sqrt_s'], {'min': 0.0}) + self.assertEqual(data['madnis']['adam_eps'], 1e-8) + + def test_round_trip(self): + """write() then read() preserves every value""" + rc = bannermod.RunCardMG7() + rc['generation']['events'] = 250 + rc['phasespace']['mode'] = 'flat' + out = io.StringIO() + rc.write(out, template=self.template) + rc2 = bannermod.RunCardMG7(out.getvalue()) + self.assertEqual(rc2['generation']['events'], 250) + self.assertEqual(rc2['phasespace']['mode'], 'flat') + self.assertEqual(rc2['multiparticles']['lepton'], rc['multiparticles']['lepton']) + self.assertEqual(rc2['cuts']['jet-delta_r'], {'min': 0.4}) + + def test_allowed_value(self): + """allowed-value enforcement preserves the previous value on bad input""" + rc = bannermod.RunCardMG7() + rc.set('phasespace.mode', 'not_a_mode', raiseerror=False) + self.assertEqual(rc['phasespace']['mode'], 'both') + rc.set('phasespace.mode', 'multichannel', raiseerror=False) + self.assertEqual(rc['phasespace']['mode'], 'multichannel') + + def test_create_default_for_process(self): + """process dependent defaults: lepton collider drops jet cuts""" + class PC(dict): + def __init__(self): + super().__init__(ninitial=2, loop_induced=False, colored_pdgs=[]) + + def proc(initial): + legs = [{'state': False, 'id': i} for i in initial] + legs.append({'state': True, 'id': 11}) + return [{'legs': legs}] + + rc = bannermod.RunCardMG7() + rc.create_default_for_process(PC(), '', [proc([11, -11])]) + self.assertIs(rc['beam']['leptonic'], True) + self.assertEqual(rc['beam']['e_cm'], 1000.0) + self.assertFalse([k for k in rc['cuts'] if k.startswith('jet')]) + + MadLoopParam = bannermod.MadLoopParam class TestMadLoopParam(unittest.TestCase): """ A class to test the MadLoopParam functionality """ From f86f70729f144edac16ea2ef8935459c6bbc378b Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 1 Jul 2026 02:39:43 +0200 Subject: [PATCH 02/12] Let the interactive card editor edit the mg7 run_card.toml via set Wire the TOML run_card into the "Do you want to edit a card?" question so the set command edits it just like the legacy run_card.dat, and add madevent-style conveniences. - common_run_interface: get_path recognizes *_card.toml; update_dependent skips its beam-dependent block when the run_card has no lpp1 (i.e. for RunCardMG7), so the generic editor works with the TOML card. - mg7 ask_edit_cards: point run_default at run_card_default.toml (enables "set default") and drop the reload no-op so edits persist. - set command support (handled in RunCardMG7 + the mg7 do_set hook): * set
. value for the fixed parameters; * cut editing: set -. value; * energy units (eV/keV/MeV/GeV/TeV/PeV) converted to GeV for energies/cuts; * arithmetic (+ - * / **) and param_card mass references (mz, mh, mt, ...); * shortcuts: no_parton_cut, lhc/lep/ilc/lcc, fixed_scale. Integer/float k/M suffixes keep working (same ConfigFile.format_variable). Co-Authored-By: Claude Opus 4.8 --- madgraph/interface/common_run_interface.py | 11 ++-- .../iolibs/template_files/mg7/madevent.py | 55 ++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/madgraph/interface/common_run_interface.py b/madgraph/interface/common_run_interface.py index 9762c8e3b..055084b12 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -5325,15 +5325,15 @@ def get_path(self, name, cards): if isinstance(cards, list): if name in cards: return True - elif '%s_card.dat' % name in cards: + elif '%s_card.dat' % name in cards or '%s_card.toml' % name in cards: return True elif name in self.paths and self.paths[name] in cards: return True else: cardnames = [os.path.basename(p) for p in cards] - if '%s_card.dat' % name in cardnames: + if '%s_card.dat' % name in cardnames or '%s_card.toml' % name in cardnames: return True - else: + else: return False elif isinstance(cards, dict) and name in cards: @@ -7231,7 +7231,10 @@ def handle_alarm(signum, frame): else: log_level=20 - if run_card and (run_card['lpp1'] !=0 or run_card['lpp2'] !=0): + if run_card and 'lpp1' in run_card and (run_card['lpp1'] !=0 or run_card['lpp2'] !=0): + # The beam-dependent alpha_s/PDF reset only applies to the LO/NLO + # run_card (which defines lpp1/lpp2). Other run_card flavours (e.g. + # the TOML run_card of the mg7 mode) skip this block. # They are likely case like lpp=+-3, where alpas not need reset # but those have dedicated name of pdf avoid the reset as_for_pdf = {'cteq6_m': 0.118, diff --git a/madgraph/iolibs/template_files/mg7/madevent.py b/madgraph/iolibs/template_files/mg7/madevent.py index 6cc70bf39..37ab783f0 100644 --- a/madgraph/iolibs/template_files/mg7/madevent.py +++ b/madgraph/iolibs/template_files/mg7/madevent.py @@ -1347,8 +1347,61 @@ def define_paths(self, **opt): old_define_paths(self, **opt) self.paths["run"] = os.path.join(self.me_dir, "Cards", "run_card.toml") self.paths["run_card.toml"] = os.path.join(self.me_dir, "Cards", "run_card.toml") + # the TOML run_card uses its own default file (concrete defaults written + # at output time); this powers "set default". + self.paths["run_default"] = os.path.join(self.me_dir, "Cards", "run_card_default.toml") AskforEditCard.define_paths = define_paths - AskforEditCard.reload_card = lambda self, path: None + + # Extra "set" handling for the TOML run_card: madevent-style shortcuts + # (lhc/lep/fixed_scale/no_parton_cut), cut editing, energy units and + # arithmetic/mass expressions. The generic editor only knows the fixed + # [section] parameters, so these are intercepted before delegating. + old_do_set = AskforEditCard.do_set + def do_set(self, line, *args, **kwargs): + targs = self.split_arg(line) + run_card = getattr(self, "run_card", None) + if isinstance(run_card, RunCardMG7) and targs: + start = 1 if targs[0] == "run_card" else 0 + if len(targs) > start: + name = targs[start] + nlow = name.lower() + rest = " ".join(targs[start + 1:]).split("#")[0].strip() + masses = run_card.get_mass_shortcuts(getattr(self, "param_card", None)) + + # --- shortcuts --- + if nlow in ("no_parton_cut", "nocut", "no_cut"): + run_card.remove_all_cut() + logger.info("removing all cuts from the run_card.toml") + self.modified_card.add("run") + return + if nlow in ("lhc", "lep", "ilc", "lcc") and rest: + ecm = run_card.set_collider(nlow, rest, masses) + logger.info("set %s collider: e_cm = %s GeV", nlow, ecm) + self.modified_card.add("run") + return + if nlow == "fixed_scale" and rest: + val = run_card.set_fixed_scale(rest, masses) + logger.info("set fixed scales to %s GeV", val) + self.modified_card.add("run") + return + + # --- cut editing (with units/math/mass) --- + if rest and run_card.is_cut_name(name): + cut, bound, val = run_card.set_cut(name, run_card.evaluate(rest, masses)) + logger.info("modify cut %s.%s of the run_card.toml to %s", cut, bound, val) + self.modified_card.add("run") + return + + # --- numeric params: resolve units/arithmetic/masses --- + if rest and nlow in [k.lower() for k in run_card.keys()]: + current = run_card[nlow] + if isinstance(current, (int, float)) and not isinstance(current, bool): + resolved = run_card.evaluate(rest, masses) + if not isinstance(resolved, str): + prefix = "run_card " if start == 1 else "" + line = "%s%s %s" % (prefix, name, resolved) + return old_do_set(self, line, *args, **kwargs) + AskforEditCard.do_set = do_set cmd = MG7Cmd() CommonRunCmd.ask_edit_card_static( From 6817c7fd618927151ff61cf2b593ff96ee4e049f Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 1 Jul 2026 02:40:06 +0200 Subject: [PATCH 03/12] Make the madspace auto-install non-interactive under scripting The one-off madspace installer used raw input(), so a scripted run (./bin/mg5_aMC , piped stdin, or -f) would hang on its prompts. - madspace/install.py: route ask_yes_no/ask_string/the compile-option and build-type menus through MG5's cmd.ask when madgraph is importable (falling back to input()), so non-interactive runs take the defaults without blocking; add a -f/--force flag. - mg7 madevent.py: launch the installer with madgraph on PYTHONPATH (so it can import cmd.ask), pass -f and stdin=DEVNULL when the parent run is non-interactive, so it never consumes the run's scripted card commands. - do_launch (mg7): when MG5 runs non-interactively, drive bin/generate_events from the card-editing lines that follow `launch`, piping them on stdin. This makes the subprocess non-interactive (installer takes defaults) and delivers the scripted `set` commands to the run_card editor. Co-Authored-By: Claude Opus 4.8 --- madgraph/interface/madgraph_interface.py | 35 ++++++- .../iolibs/template_files/mg7/madevent.py | 24 ++++- madspace/install.py | 99 +++++++++++++++++-- 3 files changed, 143 insertions(+), 15 deletions(-) diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index 66a6478f2..42dbe384e 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -7963,12 +7963,43 @@ def run(): shell = isinstance(self, cmd.CmdShell), options=self.options,**options) elif args[0] == 'mg7': + me_dir = args[1] + # When MG5 runs non-interactively (a command file / piped input), + # drive bin/generate_events from the card-editing commands that + # follow `launch` in the script. Feeding them on stdin also makes + # the subprocess non-interactive, so the one-off madspace install + # runs with defaults instead of blocking on a prompt. + scripted = not self.use_rawinput + feed_lines = [] + if scripted and self.inputfile is not None: + stop_prefixes = ('generate', 'add process', 'define', 'output', + 'launch', 'import', 'quit', 'exit') + while True: + try: + nxt = next(self.inputfile) + except (StopIteration, TypeError): + break + stripped = nxt.replace('\n', '').strip() + if not stripped: + continue + if stripped.lower().startswith(stop_prefixes): + self.store_line(nxt) # belongs to MG5, hand it back + break + feed_lines.append(stripped) + if stripped.lower() in ('done', '0'): + break + class ext_program: @staticmethod def run(): - os.chdir(args[1]) + os.chdir(me_dir) + gen = os.path.join("bin", "generate_events") try: - subprocess.run(os.path.join("bin", "generate_events")) + if scripted: + stdin_text = "\n".join(feed_lines + ["done"]) + "\n" + subprocess.run([gen], input=stdin_text, text=True) + else: + subprocess.run(gen) except KeyboardInterrupt: pass diff --git a/madgraph/iolibs/template_files/mg7/madevent.py b/madgraph/iolibs/template_files/mg7/madevent.py index 37ab783f0..21c4f56e8 100644 --- a/madgraph/iolibs/template_files/mg7/madevent.py +++ b/madgraph/iolibs/template_files/mg7/madevent.py @@ -12,22 +12,38 @@ import logging from dataclasses import dataclass from typing import Literal, NamedTuple -import tomllib import resource # Locate the madspace installation bundled alongside MadGraph. # madgraph/__init__.py lives one level below the MadGraph root, so .parents[1] # reaches the root and then "madspace/install" is the local install prefix. import madgraph as _mg_pkg -_MADSPACE_DIR = Path(_mg_pkg.__file__).parents[1] / "madspace" +_MG_ROOT = Path(_mg_pkg.__file__).parents[1] +_MADSPACE_DIR = _MG_ROOT / "madspace" _INSTALL_DIR = _MADSPACE_DIR / "install" if not (_INSTALL_DIR / "madspace").is_dir(): print() print("You don't have madspace installed for this madgraph instance") - print("Running interactive madspace installation script") + print("Running the madspace installation script") print() - _result = subprocess.run([sys.executable, str(_MADSPACE_DIR / "install.py")]) + _install_cmd = [sys.executable, str(_MADSPACE_DIR / "install.py")] + # Propagate non-interactive context (parent -f or piped stdin) so the + # installer routes its questions through cmd.ask in force mode rather than + # blocking on input(); also expose madgraph on PYTHONPATH so the installer + # subprocess can import cmd.ask in the first place. + _noninteractive = "-f" in sys.argv or not sys.stdin.isatty() + # When non-interactive, run the installer with defaults and keep it away + # from our stdin (which may carry the run's scripted card-editing commands); + # when interactive, let it share the terminal so the user can answer. + _install_stdin = subprocess.DEVNULL if _noninteractive else None + if _noninteractive: + _install_cmd.append("-f") + _install_env = os.environ.copy() + _install_env["PYTHONPATH"] = os.pathsep.join( + [str(_MG_ROOT)] + ([_install_env["PYTHONPATH"]] if _install_env.get("PYTHONPATH") else []) + ) + _result = subprocess.run(_install_cmd, env=_install_env, stdin=_install_stdin) if _result.returncode != 0: raise RuntimeError("madspace installation failed — see output above") if str(_INSTALL_DIR) not in sys.path: diff --git a/madspace/install.py b/madspace/install.py index bfe5e8dd8..ec4ee654b 100644 --- a/madspace/install.py +++ b/madspace/install.py @@ -39,23 +39,85 @@ # Interactive helpers +# +# Prompts are routed through MadGraph's cmd.ask when it is importable, so that +# non-interactive / scripted runs behave consistently with the rest of MG5 +# (piped stdin, the launcher's -f flag, input command files): the default is +# taken without blocking instead of hanging on input(). When madgraph is not +# importable (truly standalone use) we fall back to input(), still honouring +# the non-interactive flag. + +_NONINTERACTIVE = (not sys.stdin.isatty()) or any( + a in ("-f", "--force", "-y", "--yes") for a in sys.argv[1:] +) + + +def _set_noninteractive(flag: bool) -> None: + """Force non-interactive mode (called from main once args are parsed).""" + global _NONINTERACTIVE + if flag: + _NONINTERACTIVE = True + + +class _Prompter: + """Ask a question through MG5's cmd.ask when available, else input().""" + + _cmd = False # False = not tried yet, None = unavailable + + @classmethod + def _get_cmd(cls): + if cls._cmd is False: + try: + from madgraph.interface.extended_cmd import Cmd + cls._cmd = Cmd() + except Exception: + cls._cmd = None + return cls._cmd + + @classmethod + def ask(cls, question: str, default: str, choices=None) -> str: + default = str(default) + cmd = cls._get_cmd() + if cmd is not None: + try: + ans = cmd.ask( + question, default, choices=list(choices or []), + timeout=0, force=_NONINTERACTIVE, + ) + return default if ans is None else str(ans) + except Exception: + pass + # standalone fallback (no madgraph on path) + if _NONINTERACTIVE: + return default + try: + raw = input(f"{question} [{default}]: ").strip() + except EOFError: + return default + return raw if raw else default + + +def _ask(question, default, choices=None) -> str: + return _Prompter.ask(question, default, choices) def ask_yes_no(prompt: str, default: bool = True) -> bool: - hint = "Y/n" if default else "y/N" + default_s = "y" if default else "n" while True: - raw = input(f"{prompt} [{hint}]: ").strip().lower() + raw = _ask(prompt, default_s, choices=["y", "n"]).strip().lower() if not raw: return default if raw in ("y", "yes"): return True if raw in ("n", "no"): return False + if _NONINTERACTIVE: + return default print(" Please enter 'y' or 'n'.") def ask_string(prompt: str, default: str) -> str: - raw = input(f"{prompt} [default: {default}]: ").strip() + raw = _ask(prompt, default).strip() return raw if raw else default @@ -128,19 +190,26 @@ def _checked(output_key, invert): else: hint = "Enter for none" + def _defaults(): + # Convert checkbox state back to output values + return {ok: (prev[mk] != inv) for mk, _, ok, inv in entries} + while True: - raw = input( - f"Enter numbers separated by commas/spaces, or press {hint}: " + raw = _ask( + f"Enter numbers separated by commas/spaces, or press Enter ({hint})", "" ).strip() if not raw: - # Convert checkbox state back to output values - return {ok: (prev[mk] != inv) for mk, _, ok, inv in entries} + return _defaults() try: chosen = {int(x) for x in raw.replace(",", " ").split()} except ValueError: + if _NONINTERACTIVE: + return _defaults() print(" Invalid input — please enter numbers, e.g. 1,3 or 1 3") continue if not all(1 <= c <= len(entries) for c in chosen): + if _NONINTERACTIVE: + return _defaults() print(f" Numbers must be between 1 and {len(entries)}.") continue return { @@ -169,17 +238,21 @@ def ask_build_type(saved: dict) -> str: for i, (key, label) in enumerate(options, 1): marker = " [*]" if key == current else "" print(f" {i}. {label}{marker}") - hint = f"Enter to keep {current}" if current != "Release" else "Enter for Release" + hint = f"keep {current}" if current != "Release" else "Release" while True: - raw = input(f"Choose (1-{len(options)}), or press {hint}: ").strip() + raw = _ask(f"Choose (1-{len(options)}), or press Enter ({hint})", "").strip() if not raw: return current try: idx = int(raw) except ValueError: + if _NONINTERACTIVE: + return current print(f" Please enter a number between 1 and {len(options)}.") continue if not 1 <= idx <= len(options): + if _NONINTERACTIVE: + return current print(f" Please enter a number between 1 and {len(options)}.") continue return options[idx - 1][0] @@ -271,6 +344,13 @@ def main() -> None: default=False, help="Re-install non-interactively using saved settings; falls back to built-in defaults.", ) + parser.add_argument( + "-f", + "--force", + action="store_true", + default=False, + help="Do not prompt; accept the defaults (for scripted/non-interactive use).", + ) parser.add_argument( "--system", action="store_true", @@ -362,6 +442,7 @@ def main() -> None: # None = not provided by user; overridden by set_defaults below parser.set_defaults(cuda=None, hip=None, openblas=None, simd=None, build_type=None) args = parser.parse_args() + _set_noninteractive(args.force or args.yes) # Load saved settings when a previous installation is present saved = load_settings() if (INSTALL_DIR / "madspace").is_dir() else {} From aa2db1a8a8fc8322e6f3dc0c11645844e09b8c27 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 1 Jul 2026 02:48:09 +0200 Subject: [PATCH 04/12] madspace install: use cmake from MG5's HEPTools when system cmake is missing The source build (scikit-build-core) needs cmake >= 3.15 on PATH and fails with CMakeNotFoundError when the system has none (or too old a version). Discover a suitable cmake before the build and put it on the build env's PATH, checking in order: a MADGRAPH_CMAKE/CMAKE override, the system cmake (skipped if too old), then a cmake installed through MadGraph's HEPTools installer (/cmake/bin/cmake). The HEPTools location is a configurable MG5 option (heptools_install_dir) that may point outside MG5DIR, so it is resolved from: MADGRAPH_HEPTOOLS_DIR (exported by do_launch), then the MG5 configuration files (~/.mg5, XDG, /input/mg5_configuration.txt) for direct invocations, then the default /HEPTools. If no cmake is found, print a hint pointing at 'install cmake' / MADGRAPH_CMAKE instead of failing deep inside pip. Co-Authored-By: Claude Opus 4.8 --- madgraph/interface/madgraph_interface.py | 14 ++- madspace/install.py | 109 +++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index 42dbe384e..c8846a793 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -7989,6 +7989,16 @@ def run(): if stripped.lower() in ('done', '0'): break + # Expose the configured HEPTools location so that the one-off + # madspace build can pick up a cmake installed there via MG5's + # 'install cmake' (heptools_install_dir may point outside MG5DIR). + gen_env = os.environ.copy() + heptools_dir = self.options.get('heptools_install_dir') + if heptools_dir: + if not os.path.isabs(heptools_dir): + heptools_dir = os.path.join(MG5DIR, heptools_dir) + gen_env['MADGRAPH_HEPTOOLS_DIR'] = os.path.abspath(heptools_dir) + class ext_program: @staticmethod def run(): @@ -7997,9 +8007,9 @@ def run(): try: if scripted: stdin_text = "\n".join(feed_lines + ["done"]) + "\n" - subprocess.run([gen], input=stdin_text, text=True) + subprocess.run([gen], input=stdin_text, text=True, env=gen_env) else: - subprocess.run(gen) + subprocess.run(gen, env=gen_env) except KeyboardInterrupt: pass diff --git a/madspace/install.py b/madspace/install.py index ec4ee654b..d9a8bc78a 100644 --- a/madspace/install.py +++ b/madspace/install.py @@ -13,6 +13,8 @@ import json import os import platform +import re +import shutil import subprocess import sys import tomllib @@ -258,6 +260,111 @@ def ask_build_type(saved: dict) -> str: return options[idx - 1][0] +# CMake discovery +# +# The source build (scikit-build-core) needs cmake >= 3.15 on PATH. When it is +# missing (or too old) we fall back to a cmake installed through MadGraph's +# HEPTools installer (`install cmake` in MG5), which lives next to this package +# under /HEPTools. An explicit MADGRAPH_CMAKE / CMAKE env var wins. + +CMAKE_MIN_VERSION = (3, 15) + + +def _cmake_version_ok(path, minimum=CMAKE_MIN_VERSION) -> bool: + try: + out = subprocess.run( + [str(path), "--version"], capture_output=True, text=True, timeout=30 + ).stdout + except Exception: + return False + m = re.search(r"(\d+)\.(\d+)", out) + return bool(m) and (int(m.group(1)), int(m.group(2))) >= minimum + + +def _heptools_dir_from_config() -> str | None: + """Read heptools_install_dir from the MG5 configuration files (same + locations MG5 itself uses), so the value is available even when the + installer is run directly rather than launched from MG5.""" + home = os.environ.get("HOME") or os.path.expanduser("~") + candidates = [] + if home: + candidates.append(os.path.join(home, ".mg5", "mg5_configuration.txt")) + xdg = os.environ.get("XDG_CONFIG_HOME", os.path.join(home, ".config")) + candidates.append(os.path.join(xdg, "mg5_configuration.txt")) + candidates.append(str(SCRIPT_DIR.parent / "input" / "mg5_configuration.txt")) + for cfg in candidates: + try: + with open(cfg) as f: + for line in f: + line = line.split("#", 1)[0].strip() + if line.startswith("heptools_install_dir"): + val = line.partition("=")[2].strip() + if val: + if not os.path.isabs(val): + val = os.path.join(str(SCRIPT_DIR.parent), val) + return val + except OSError: + continue + return None + + +def find_cmake() -> str | None: + """Return a path to a cmake >= CMAKE_MIN_VERSION, or None.""" + # 1. explicit override + for var in ("MADGRAPH_CMAKE", "CMAKE"): + p = os.environ.get(var) + if p and _cmake_version_ok(p): + return p + # 2. system cmake on PATH (ignored if too old) + p = shutil.which("cmake") + if p and _cmake_version_ok(p): + return p + # 3. cmake installed via MadGraph's HEPTools installer. The HEPTools + # location is configurable in MG5 (heptools_install_dir); MG5 passes it + # down as MADGRAPH_HEPTOOLS_DIR. Fall back to the default /HEPTools. + hep_dirs = [] + env_hep = os.environ.get("MADGRAPH_HEPTOOLS_DIR") + if env_hep: + hep_dirs.append(Path(env_hep)) + cfg_hep = _heptools_dir_from_config() + if cfg_hep: + hep_dirs.append(Path(cfg_hep)) + hep_dirs.append(SCRIPT_DIR.parent / "HEPTools") + seen = set() + for heptools in hep_dirs: + for pattern in ("cmake/bin/cmake", "bin/cmake", "cmake", + "cmake*/bin/cmake", "*/bin/cmake"): + for cand in sorted(heptools.glob(pattern)): + cand = cand.resolve() + if cand in seen: + continue + seen.add(cand) + if cand.is_file() and _cmake_version_ok(cand): + return str(cand) + return None + + +def add_cmake_to_path(env: dict) -> dict: + """Ensure a suitable cmake (and co-located tools such as ninja) is on the + PATH of the build environment.""" + cmake = find_cmake() + if cmake: + cmake_dir = os.path.dirname(os.path.abspath(cmake)) + parts = [cmake_dir] + if existing := env.get("PATH"): + parts.append(existing) + env["PATH"] = os.pathsep.join(parts) + print(f"Using cmake: {cmake}") + else: + print( + "WARNING: no cmake >= %d.%d found. The source build will likely fail.\n" + " Install one with MG5 ('install cmake'), via your package manager,\n" + " or point to it with MADGRAPH_CMAKE=/path/to/cmake." + % CMAKE_MIN_VERSION + ) + return env + + # Command execution @@ -296,6 +403,8 @@ def install_build_deps(system: bool = False) -> dict: if existing := env.get("PYTHONPATH"): pythonpath_parts.append(existing) env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts) + # make sure the source build can find a recent-enough cmake + env = add_cmake_to_path(env) return env From bca2601832551efe07177b7d959947bbdd6cc61a Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 1 Jul 2026 09:27:32 +0200 Subject: [PATCH 05/12] mg7 card editor: robustly load run_card.toml as RunCardMG7 Ensure the interactive editor always has the TOML run_card loaded as a RunCardMG7, independently of whether the generic AskforEditCard.get_path recognised run_card.toml. Previously, if init_run left self.run_card as {} (older common_run_interface not recognising the .toml card, an unexpected me_dir, ...), every "set ..." was rejected with "invalid set command". Override init_run in the mg7 ask_edit_cards patch to load Cards/run_card.toml directly when self.run_card is not already a RunCardMG7. Co-Authored-By: Claude Opus 4.8 --- .../iolibs/template_files/mg7/madevent.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/madgraph/iolibs/template_files/mg7/madevent.py b/madgraph/iolibs/template_files/mg7/madevent.py index 21c4f56e8..9d93fdb07 100644 --- a/madgraph/iolibs/template_files/mg7/madevent.py +++ b/madgraph/iolibs/template_files/mg7/madevent.py @@ -1368,6 +1368,25 @@ def define_paths(self, **opt): self.paths["run_default"] = os.path.join(self.me_dir, "Cards", "run_card_default.toml") AskforEditCard.define_paths = define_paths + # Make sure the run_card is loaded as a RunCardMG7 regardless of whether the + # generic editor recognised run_card.toml (older common_run_interface, an + # unexpected me_dir, ...). Without this self.run_card can stay {} and every + # "set " is rejected as an invalid command. + old_init_run = AskforEditCard.init_run + def init_run(self, cards): + out = old_init_run(self, cards) + if not isinstance(getattr(self, "run_card", None), RunCardMG7): + toml_path = self.paths.get("run") or os.path.join( + self.me_dir, "Cards", "run_card.toml") + if os.path.exists(toml_path): + try: + self.run_card = RunCardMG7(toml_path, consistency="warning") + self.run_set = list(self.run_card.keys()) + except Exception as err: + logger.warning("could not load %s: %s", toml_path, err) + return getattr(self, "run_set", out) + AskforEditCard.init_run = init_run + # Extra "set" handling for the TOML run_card: madevent-style shortcuts # (lhc/lep/fixed_scale/no_parton_cut), cut editing, energy units and # arithmetic/mass expressions. The generic editor only knows the fixed From 869d15a30ffa735a8c3b7e1169ea5d176fec6306 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 1 Jul 2026 09:39:35 +0200 Subject: [PATCH 06/12] detect lhapdf from HEPTools, fix an issue with the modified_card setup --- madgraph/interface/common_run_interface.py | 7 +++++-- madgraph/interface/madgraph_interface.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/madgraph/interface/common_run_interface.py b/madgraph/interface/common_run_interface.py index 055084b12..0244aea04 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -8070,8 +8070,11 @@ def open_file(self, answer): if answer in self.modified_card: self.write_card(answer) - elif os.path.basename(answer.replace('_card.dat','')) in self.modified_card: - self.write_card(os.path.basename(answer.replace('_card.dat',''))) + else: + short = os.path.basename( + answer.replace('_card.dat', '').replace('_card.toml', '')) + if short in self.modified_card: + self.write_card(short) start = time.time() try: diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index c8846a793..cdb34b642 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -7999,6 +7999,25 @@ def run(): heptools_dir = os.path.join(MG5DIR, heptools_dir) gen_env['MADGRAPH_HEPTOOLS_DIR'] = os.path.abspath(heptools_dir) + # Point the run at the configured LHAPDF data directory (e.g. a + # lhapdf6 installed via 'install lhapdf6') so it can find the PDF + # sets without the user having to set LHAPDF_DATA_PATH by hand. + if 'LHAPDF_DATA_PATH' not in gen_env: + for _opt in ('lhapdf', 'lhapdf_py3'): + _val = self.options.get(_opt) + if not _val: + continue + _exe = _val.split()[0] # strip any '--python=' suffix + try: + _datadir = subprocess.check_output( + [_exe, '--datadir'], text=True, + stderr=subprocess.DEVNULL).strip() + except Exception: + continue + if _datadir and os.path.isdir(_datadir): + gen_env['LHAPDF_DATA_PATH'] = _datadir + break + class ext_program: @staticmethod def run(): From c1311af574823f110be0c35ae369f7ddfa323455 Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Wed, 1 Jul 2026 13:09:44 +0200 Subject: [PATCH 07/12] make it possible to set run card keys without section name if unambiguous --- madgraph/interface/common_run_interface.py | 15 +++++++++++++++ madgraph/various/banner.py | 22 +++++++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/madgraph/interface/common_run_interface.py b/madgraph/interface/common_run_interface.py index 0244aea04..93f68481b 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -6321,6 +6321,21 @@ def do_set(self, line): return #### RUN CARD + # For mg7 TOML run cards, resolve bare keys (e.g. 'events' -> 'generation.events') + # before the membership check below. + if card in ('', 'run_card') and hasattr(self.run_card, 'toml_sections') \ + and '.' not in args[start]: + matches = ['%s.%s' % (sec, args[start]) + for sec, keys in self.run_card.toml_sections.items() + if args[start] in keys] + if len(matches) == 1: + args[start] = matches[0] + elif len(matches) > 1: + logger.warning( + "Ambiguous key %r — use the full section.key form, e.g.: %s", + args[start], ' or '.join(matches)) + return + if args[start] in [l.lower() for l in self.run_card.keys()] and card in ['', 'run_card']: if args[start] not in self.run_set: diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index fd308f590..c9ed6125a 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6660,9 +6660,25 @@ def parse_energy(cls, value): def set(self, name, value, *args, **opts): """Like :meth:`ConfigFile.set` but understands energy units for the - energy-dimensioned parameters (e.g. ``set beam.e_cm 13 TeV``).""" - if isinstance(name, str) and name.lower() in self.energy_params: - value = self.parse_energy(value) + energy-dimensioned parameters (e.g. ``set beam.e_cm 13 TeV``) and + resolves bare key names (e.g. ``set events 1000``) when unambiguous + across all sections.""" + if isinstance(name, str): + lname = name.lower() + if '.' not in lname: + matches = ['%s.%s' % (sec, lname) + for sec, keys in self.toml_sections.items() + if lname in keys] + if len(matches) == 1: + name = matches[0] + lname = name + elif len(matches) > 1: + logger.warning( + "Ambiguous key %r — use the full section.key form, e.g.: %s", + name, ' or '.join(matches)) + return + if lname in self.energy_params: + value = self.parse_energy(value) return super(RunCardMG7, self).set(name, value, *args, **opts) def is_cut_name(self, name): From b6df78cd1b051744f6df3faf38ddde3dafa9bc37 Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Wed, 1 Jul 2026 13:40:50 +0200 Subject: [PATCH 08/12] create reduced run card for gridpacks, keep full run card to document run settings --- .../iolibs/template_files/mg7/gridpack.py | 2 +- .../iolibs/template_files/mg7/madevent.py | 17 ++++- madgraph/various/banner.py | 70 ++++++++++++++----- 3 files changed, 69 insertions(+), 20 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/gridpack.py b/madgraph/iolibs/template_files/mg7/gridpack.py index 165f1d16c..c738bacb3 100644 --- a/madgraph/iolibs/template_files/mg7/gridpack.py +++ b/madgraph/iolibs/template_files/mg7/gridpack.py @@ -11,7 +11,7 @@ def main() -> None: # load run card and metadata. Use the RunCardMG7 representation when the # madgraph package is importable; gridpacks are meant to be portable, so # fall back to a plain tomllib parse otherwise (the card is the same TOML). - run_card_path = os.path.join("Cards", "run_card.toml") + run_card_path = os.path.join("Cards", "grid_run_card.toml") try: from madgraph.various.banner import RunCardMG7 run_card = RunCardMG7(run_card_path) diff --git a/madgraph/iolibs/template_files/mg7/madevent.py b/madgraph/iolibs/template_files/mg7/madevent.py index 9d93fdb07..f8ddbbecb 100644 --- a/madgraph/iolibs/template_files/mg7/madevent.py +++ b/madgraph/iolibs/template_files/mg7/madevent.py @@ -736,9 +736,20 @@ def save_gridpack(self) -> None: cards_path = os.path.join(gridpack_path, "Cards") os.mkdir(cards_path) shutil.copy(os.path.join("Cards", "param_card.dat"), cards_path) - # Re-emit the full run_card from its Python representation so the - # gridpack copy stays in sync with every parameter (and its template). - self.run_card.write(os.path.join(cards_path, "run_card.toml")) + # Full run card with a header noting it is read-only in gridpack context. + import io as _io + _buf = _io.StringIO() + self.run_card.write(_buf) + _header = ( + "# This is the run card used to generate this gridpack.\n" + "# Modifying this file will have no effect on gridpack execution.\n" + "# To change event-generation settings, edit grid_run_card.toml.\n\n" + ) + with open(os.path.join(cards_path, "run_card.toml"), 'w') as _f: + _f.write(_header + _buf.getvalue()) + # Minimal card containing only the settings used by generate_events. + self.run_card.write_gridpack_card( + os.path.join(cards_path, "grid_run_card.toml")) bin_path = os.path.join(gridpack_path, "bin") os.mkdir(bin_path) diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index c9ed6125a..c77012aae 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6402,6 +6402,8 @@ def __init__(self, *args, **opts): self.toml_sections = collections.OrderedDict() # internal_key -> section self.section_of = {} + # set of internal keys (section.key) relevant during gridpack execution + self.gridpack_params = set() # free-form sections kept as nested dicts self.dynamic_sections = collections.OrderedDict() # unknown sections preserved for round-trip @@ -6411,8 +6413,11 @@ def __init__(self, *args, **opts): # ------------------------------------------------------------------ # parameter declaration # ------------------------------------------------------------------ - def add_toml_param(self, section, key, value, **opts): - """Declare one fixed (typed) TOML parameter belonging to ``section``.""" + def add_toml_param(self, section, key, value, gridpack=False, **opts): + """Declare one fixed (typed) TOML parameter belonging to ``section``. + + ``gridpack=True`` marks the parameter as relevant during gridpack + execution; such params are written to ``grid_run_card.toml``.""" section = section.lower() key = key.lower() internal = '%s.%s' % (section, key) @@ -6423,23 +6428,25 @@ def add_toml_param(self, section, key, value, **opts): self.toml_sections.setdefault(section, []) if key not in self.toml_sections[section]: self.toml_sections[section].append(key) + if gridpack: + self.gridpack_params.add(internal) def default_setup(self): """Define every parameter of the default ``run_card.toml``.""" # ----------------------------- [run] -------------------------- - self.add_toml_param('run', 'run_name', "run") - self.add_toml_param('run', 'devices', ["cppnone"], typelist=str, + self.add_toml_param('run', 'run_name', "run", gridpack=True) + self.add_toml_param('run', 'devices', ["cppnone"], typelist=str, gridpack=True, comment="options: cuda, hip, cpp, cppnone, cppsse4, cppavx2, cpp512y, cpp512z, cppauto") self.add_toml_param('run', 'simd_vector_size', -1, comment="-1 chooses automatically; on x86: 1, 4, 8; on Apple silicon: 1, 2") - self.add_toml_param('run', 'cpu_thread_pool_size', -1, + self.add_toml_param('run', 'cpu_thread_pool_size', -1, gridpack=True, comment="-1 sets count automatically based on number of CPUs") - self.add_toml_param('run', 'gpu_thread_pool_size', 1) - self.add_toml_param('run', 'combine_thread_pool_size', -1) - self.add_toml_param('run', 'output_format', "compact_npy", + self.add_toml_param('run', 'gpu_thread_pool_size', 1, gridpack=True) + self.add_toml_param('run', 'combine_thread_pool_size', -1, gridpack=True) + self.add_toml_param('run', 'output_format', "compact_npy", gridpack=True, allowed=['compact_npy', 'lhe_npy', 'lhe']) - self.add_toml_param('run', 'verbosity', "pretty", + self.add_toml_param('run', 'verbosity', "pretty", gridpack=True, allowed=['silent', 'pretty', 'log']) self.add_toml_param('run', 'dummy_matrix_element', False) self.add_toml_param('run', 'save_gridpack', False) @@ -6459,16 +6466,16 @@ def default_setup(self): 'half_transverse_mass', 'partonic_energy']) # -------------------------- [generation] ---------------------- - self.add_toml_param('generation', 'events', 100000) - self.add_toml_param('generation', 'max_overweight_truncation', 0.01) - self.add_toml_param('generation', 'freeze_max_weight_after', 10000) - self.add_toml_param('generation', 'cpu_batch_size', 1000) - self.add_toml_param('generation', 'gpu_batch_size', 64000) + self.add_toml_param('generation', 'events', 100000, gridpack=True) + self.add_toml_param('generation', 'max_overweight_truncation', 0.01, gridpack=True) + self.add_toml_param('generation', 'freeze_max_weight_after', 10000, gridpack=True) + self.add_toml_param('generation', 'cpu_batch_size', 1000, gridpack=True) + self.add_toml_param('generation', 'gpu_batch_size', 64000, gridpack=True) self.add_toml_param('generation', 'survey_min_iters', 3) self.add_toml_param('generation', 'survey_max_iters', 3) self.add_toml_param('generation', 'survey_target_precision', 0.1) - self.add_toml_param('generation', 'cut_efficiency_threshold', 0.7) - self.add_toml_param('generation', 'max_cut_repetitions', 1000) + self.add_toml_param('generation', 'cut_efficiency_threshold', 0.7, gridpack=True) + self.add_toml_param('generation', 'max_cut_repetitions', 1000, gridpack=True) self.add_toml_param('generation', 'systematics', False) # ----------------------------- [vegas] ------------------------ @@ -6895,6 +6902,37 @@ def write(self, output_file, template=None, python_template=True, **opt): else: output_file.write(text) + def write_gridpack_card(self, output_file): + """Write a minimal ``grid_run_card.toml`` containing only the parameters + marked ``gridpack=True`` in :meth:`default_setup`, i.e. those actually + read by the gridpack's ``generate_events`` script.""" + sections = collections.OrderedDict() + for section, keys in self.toml_sections.items(): + for key in keys: + internal = '%s.%s' % (section, key) + if internal in self.gridpack_params: + sections.setdefault(section, []).append( + (key, self[internal])) + + lines = [ + '# MadGraph7 gridpack run card', + '# Only the settings relevant during gridpack execution are included.', + '# To change physics or integration settings, re-generate the gridpack.', + '', + ] + for section, kvs in sections.items(): + lines.append('[%s]' % section) + for key, value in kvs: + lines.append('%s = %s' % (key, self.format_toml_value(value))) + lines.append('') + text = '\n'.join(lines) + + if isinstance(output_file, str): + with open(output_file, 'w') as fsock: + fsock.write(text) + else: + output_file.write(text) + # ------------------------------------------------------------------ # process dependent defaults (mirrors RunCardLO.create_default_for_process) # ------------------------------------------------------------------ From ff9e7865bfe174e2066bf50df38ead66e5e01fe6 Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Wed, 1 Jul 2026 14:01:00 +0200 Subject: [PATCH 09/12] make autocomplete work with simplified set command --- madgraph/interface/common_run_interface.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/madgraph/interface/common_run_interface.py b/madgraph/interface/common_run_interface.py index 93f68481b..75a8de140 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -5960,10 +5960,17 @@ def complete_set(self, text, line, begidx, endidx, formatting=True): possibilities['special values'] = self.list_completion(text, list(self.special_shortcut.keys())+['qcut', 'showerkt']) if 'run_card' in list(allowed.keys()): - opts = self.run_set + opts = list(self.run_set) if allowed['run_card'] == 'default': opts.append('default') - + # For RunCardMG7, also offer bare key names that are unambiguous + # (appear in exactly one section), so `set events ` works. + if hasattr(self.run_card, 'toml_sections'): + seen = {} + for sec, keys in self.run_card.toml_sections.items(): + for key in keys: + seen[key] = seen.get(key, 0) + 1 + opts += [key for key, count in seen.items() if count == 1] possibilities['Run Card'] = self.list_completion(text, opts) From 94800704e4c441b6d96a409b7fab40d1443c0fad Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 1 Jul 2026 18:07:13 +0200 Subject: [PATCH 10/12] mg7: auto-download the requested LHAPDF set if missing If the PDF set named in the run_card is not available, download it on the fly using MG5's install_lhapdf_pdfset_static, instead of crashing in init_beam. - do_launch resolves the PDF data directory following: $LHAPDF_DATA_PATH, then the configured lhapdf data dir (e.g. lhapdf6 in HEPTools), then a local writable directory; it also forwards the lhapdf-config path as MADGRAPH_LHAPDF_CONFIG so the run can fetch sets. - madevent.ensure_pdf_set() downloads the set into that directory (only when missing) and points PDF_PATH at it so madspace can load it. This runs for the final pdf choice, after any card editing. Co-Authored-By: Claude Opus 4.8 --- madgraph/interface/madgraph_interface.py | 52 +++++++++++++------ .../iolibs/template_files/mg7/madevent.py | 27 ++++++++++ 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index cdb34b642..d392fcd92 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -7999,24 +7999,42 @@ def run(): heptools_dir = os.path.join(MG5DIR, heptools_dir) gen_env['MADGRAPH_HEPTOOLS_DIR'] = os.path.abspath(heptools_dir) - # Point the run at the configured LHAPDF data directory (e.g. a - # lhapdf6 installed via 'install lhapdf6') so it can find the PDF - # sets without the user having to set LHAPDF_DATA_PATH by hand. + # Point the run at the LHAPDF data directory where PDF sets live + # (and where a missing one can be downloaded on the fly), following: + # 1. $LHAPDF_DATA_PATH if the user set it; + # 2. the data dir of the configured lhapdf (e.g. lhapdf6 installed + # via 'install lhapdf6', which lives inside HEPTools); + # 3. a local writable directory otherwise. + # The lhapdf-config executable is forwarded (MADGRAPH_LHAPDF_CONFIG) + # so the run can download the requested PDF set (see madevent + # init_beam / ensure_pdf_set). + lhapdf_exe = None + for _opt in ('lhapdf', 'lhapdf_py3'): + _val = self.options.get(_opt) + if not _val: + continue + _exe = _val.split()[0] # strip any '--python=' suffix + try: + _datadir = subprocess.check_output( + [_exe, '--datadir'], text=True, + stderr=subprocess.DEVNULL).strip() + except Exception: + continue + lhapdf_exe = _exe + if 'LHAPDF_DATA_PATH' not in gen_env and _datadir and os.path.isdir(_datadir): + gen_env['LHAPDF_DATA_PATH'] = _datadir + break if 'LHAPDF_DATA_PATH' not in gen_env: - for _opt in ('lhapdf', 'lhapdf_py3'): - _val = self.options.get(_opt) - if not _val: - continue - _exe = _val.split()[0] # strip any '--python=' suffix - try: - _datadir = subprocess.check_output( - [_exe, '--datadir'], text=True, - stderr=subprocess.DEVNULL).strip() - except Exception: - continue - if _datadir and os.path.isdir(_datadir): - gen_env['LHAPDF_DATA_PATH'] = _datadir - break + # local fallback (inside HEPTools if configured, else MG5DIR) + local_pdf = os.path.join( + gen_env.get('MADGRAPH_HEPTOOLS_DIR', MG5DIR), 'lhapdf_pdfsets') + try: + os.makedirs(local_pdf, exist_ok=True) + gen_env['LHAPDF_DATA_PATH'] = local_pdf + except OSError: + pass + if lhapdf_exe: + gen_env['MADGRAPH_LHAPDF_CONFIG'] = lhapdf_exe class ext_program: @staticmethod diff --git a/madgraph/iolibs/template_files/mg7/madevent.py b/madgraph/iolibs/template_files/mg7/madevent.py index f8ddbbecb..925f35c8d 100644 --- a/madgraph/iolibs/template_files/mg7/madevent.py +++ b/madgraph/iolibs/template_files/mg7/madevent.py @@ -279,6 +279,32 @@ def init_histograms(self) -> None: if key != "order_by" ] + def ensure_pdf_set(self, pdf_set: str) -> None: + """Make sure the requested LHAPDF set is available, downloading it if + needed. The destination follows LHAPDF_DATA_PATH, otherwise the data + dir of the configured lhapdf (e.g. lhapdf6 in HEPTools), otherwise a + local directory -- and PDF_PATH is pointed at it so madspace uses it. + Both LHAPDF_DATA_PATH and MADGRAPH_LHAPDF_CONFIG are provided by + do_launch; nothing is downloaded when the set is already present.""" + global PDF_PATH + data_path = os.environ.get("LHAPDF_DATA_PATH") or PDF_PATH + if data_path and os.path.isdir(os.path.join(data_path, pdf_set)): + PDF_PATH = data_path + return + lhapdf_config = os.environ.get("MADGRAPH_LHAPDF_CONFIG") + if not lhapdf_config: + return # can't download; the missing-PDF error is raised below + if not data_path: + data_path = os.path.join(os.getcwd(), "lhapdf_pdfsets") + try: + from madgraph.interface.common_run_interface import CommonRunCmd + os.makedirs(data_path, exist_ok=True) + logger.info("PDF set %s not found; downloading into %s", pdf_set, data_path) + CommonRunCmd.install_lhapdf_pdfset_static(lhapdf_config, data_path, pdf_set) + PDF_PATH = data_path + except Exception as err: + logger.warning("Could not download PDF set %s: %s", pdf_set, err) + def init_beam(self) -> None: beam_args = self.run_card["beam"] @@ -305,6 +331,7 @@ def init_beam(self) -> None: ) pdf_set = beam_args["pdf"] + self.ensure_pdf_set(pdf_set) if PDF_PATH is None: raise RuntimeError("Can't load lhapdf module. Please set LHAPDF_DATA_PATH manually") self.pdf_grid = ms.PdfGrid(os.path.join(PDF_PATH, pdf_set, f"{pdf_set}_0000.dat")) From 777643664cb92b94e778711c6da5754356b3826f Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 2 Jul 2026 01:02:59 +0200 Subject: [PATCH 11/12] banner: add RunCardLO -> RunCardMG7 converter (first pass) Add RunCardMG7.from_LO(runcardlo) which reproduces, as far as possible, a legacy LO run_card in the TOML run_card, for cross-checking the same setup in both and for a retro-compatible mode. Ports the directly-mappable / convertible settings (see the analysis in madgraph/various/RunCardLO_to_MG7_mapping.md): - beams: ebeam1/2 -> e_cm, lpp -> leptonic; - pdf: pdlabel/lhaid -> pdf (known labels/ids); - scales: fixed_ren_scale, scale -> ren_scale, dsqrt_q2fact1/2 -> fact_scale1/2, fixed_fac_scale, dynamical_scale_choice (int -> string); - generation: nevents -> events, gridpack, bwcutoff, SDE_strategy, maxjetflavor -> jet multiparticle, use_syst -> systematics; - cuts: pt/eta/deltaR/mass/sqrt_s for jet/bottom/lepton/photon/missing. Everything that has no MG7 equivalent (merging, bias, polarization, heavy ion, HT/energy/ordered/per-pdg cuts, helicity, ...) is collected and returned (and warned) as a "could not transfer" list, so the conversion is transparent. Includes the pre-implementation feasibility analysis and a unit test. Co-Authored-By: Claude Opus 4.8 --- madgraph/various/RunCardLO_to_MG7_mapping.md | 161 +++++++++++++++ madgraph/various/banner.py | 207 +++++++++++++++++++ tests/unit_tests/various/test_banner.py | 32 +++ 3 files changed, 400 insertions(+) create mode 100644 madgraph/various/RunCardLO_to_MG7_mapping.md diff --git a/madgraph/various/RunCardLO_to_MG7_mapping.md b/madgraph/various/RunCardLO_to_MG7_mapping.md new file mode 100644 index 000000000..a6285d766 --- /dev/null +++ b/madgraph/various/RunCardLO_to_MG7_mapping.md @@ -0,0 +1,161 @@ +# RunCardLO → RunCardMG7 conversion — feasibility map + +Goal: an auto-converter that takes a legacy LO `run_card.dat` (`banner.RunCardLO`, +~220 parameters) and produces the equivalent `run_card.toml` (`banner.RunCardMG7`), +so the *same* physics setup can be run/compared in both, and so we can offer a +"retro-compatible" launch mode. + +This file is the **analysis done before implementation**: it classifies every LO +parameter as portable / convertible / partial / not-representable, and lists what +the MG7 card would need before a given item can be carried over faithfully. + +Legend for the mapping: +- **[=]** direct: same meaning, at most a rename → safe to port. +- **[~]** convertible: needs a value transform (documented) → port with a helper. +- **[!]** partial: LO is finer-grained than MG7 can express → port the common + case, warn on the rest. +- **[x]** not representable: MG7 has no equivalent yet → cannot port; needs an MG7 + feature first (or must warn/ignore). +- **[mg7-only]** exists only in MG7 (no LO source) → keep MG7 default. + +--- + +## 1. Beams / collider + +| LO parameter(s) | MG7 target | class | notes | +|---|---|---|---| +| `ebeam1`, `ebeam2` | `beam.e_cm` | [~] | `e_cm = ebeam1 + ebeam2` for a head-on collider. Asymmetric beams keep e_cm but lose the boost. | +| `lpp1`, `lpp2` | `beam.leptonic` | [!] | `leptonic = |lpp| in {3,4}` (e/µ). MG7 only stores hadronic-vs-leptonic; it cannot express antiproton vs proton (`lpp=±1`), elastic photon (`lpp=2`), EVA (`lpp=±3/±4` variants), plugin (`lpp=9`) or the no-PDF fixed-energy beam (`lpp=0`). | +| `polbeam1`, `polbeam2` | — | [x] | beam polarization: no MG7 field. | +| `nb_proton1/2`, `nb_neutron1/2`, `mass_ion1/2` | — | [x] | heavy-ion beams: no MG7 field. | + +## 2. PDF + +| LO parameter(s) | MG7 target | class | notes | +|---|---|---|---| +| `pdlabel='lhapdf'` + `lhaid` | `beam.pdf` | [~] | map `lhaid` → LHAPDF set *name* (via `pdfsets.index`). | +| `pdlabel` built-in (`nn23lo1`, `cteq6l1`, …) | `beam.pdf` | [~] | map the ~10 built-in labels → their LHAPDF set name. | +| `pdlabel1`, `pdlabel2` (per-beam / `mixed`) | `beam.pdf` | [!] | MG7 has a single `pdf`; different PDFs per beam cannot be expressed. | +| `pdlabel='none'` / `lpp=0` | — | [!] | MG7 always uses a PDF grid; "no PDF" beams aren't expressible. | + +## 3. Scales + +| LO parameter(s) | MG7 target | class | notes | +|---|---|---|---| +| `fixed_ren_scale` | `beam.fixed_ren_scale` | [=] | | +| `scale` | `beam.ren_scale` | [=] | rename | +| `dsqrt_q2fact1`, `dsqrt_q2fact2` | `beam.fact_scale1`, `beam.fact_scale2` | [=] | rename | +| `fixed_fac_scale` (and `_scale1/_scale2`) | `beam.fixed_fact_scale` | [!] | MG7 has one flag; LO can fix beam 1 and beam 2 independently. | +| `dynamical_scale_choice` (int) | `beam.dynamical_scale_choice` (str) | [~] | clean int→string table: `1`(ΣEt)→`transverse_energy`, `2`(HT=Σ transverse mass)→`transverse_mass`, `3`(HT/2)→`half_transverse_mass`, `4`(partonic CM energy)→`partonic_energy`. `-1`(CKKW back-clustering, LO default) and `10` have no MG7 equivalent → fall back to MG7 default `half_transverse_mass` [!]; `0`(user hook) → [x]. | +| `scalefact`, `mue_over_ref`, `mue_ref_fixed`, `fixed_extra_scale` | — | [x] | scale-variation / EW-scale extras: no MG7 field. | + +## 4. Generation / run + +| LO parameter(s) | MG7 target | class | notes | +|---|---|---|---| +| `nevents` | `generation.events` | [=] | rename | +| `gridpack` | `run.save_gridpack` | [=] | | +| `run_tag` | `run.run_name` | [!] | close but not identical semantics (tag vs run name). | +| `bwcutoff` | `phasespace.bw_cutoff` | [=] | rename | +| `SDE_strategy` (int 1/2) | `phasespace.sde_strategy` (str) | [~] | `1`(single-diagram enhanced)→`diagrams`, `2`(product of denominators)→`denominators`. | +| `maxjetflavor` | `multiparticles.jet` | [~] | rebuild the jet pdg list from maxjetflavor (± up to N + gluon). | +| `use_syst` / `systematics_*` | `generation.systematics` (bool) | [!] | MG7 only has on/off; the systematics program/arguments/pdf/scale sets are lost. | +| `iseed` | — | [x] | no seed field in the MG7 card (seed handled elsewhere). | +| `nhel`, `limhel`, `hel_*` | — | [x] | helicity-sampling controls: no MG7 field (MG7 has `dummy_matrix_element` only). | + +## 5. Cuts + +MG7 expresses cuts as `[-]-.{min,max}` over the groups +`jet, bottom, lepton, missing, photon` and observables `pt, eta_abs, delta_r, +mass, sqrt_s`. Mapping of the common LO cuts: + +| LO parameter(s) | MG7 target | class | +|---|---|---| +| `ptj`/`ptjmax`, `ptb`/`ptbmax`, `pta`/`ptamax`, `ptl`/`ptlmax` | `jet-pt`, `bottom-pt`, `photon-pt`, `lepton-pt` `.min/.max` | [=] | +| `misset`/`missetmax` | `missing-pt.min/.max` | [=] | +| `etaj`, `etab`, `etaa`, `etal` | `-eta_abs.max` | [~] (LO η-max → eta_abs.max) | +| `drjj`, `drbb`, `drll`, `draa`, `drbj`, `draj`, `drjl`, `drab`, `drbl`, `dral` (+ `*max`) | `[-]-delta_r.min/.max` | [=] | +| `mmjj`, `mmbb`, `mmaa`, `mmll` (+ `*max`) | `-mass.min/.max` | [=] | +| `dsqrt_shat`/`dsqrt_shatmax` | `sqrt_s.min/.max` | [=] | + +Cuts that are **not representable** in the current MG7 cut engine ([x] unless noted): +- η **min** cuts (`etajmin`, `etabmin`, …): MG7 has only `eta_abs.max`. [!] +- energy cuts `ej/eb/ea/el` (+ max): no `energy` observable. [x] +- ordered/per-object cuts `ptj1min..ptj4max`, `ptl1min..ptl4max`, `cutuse`: no + ordered-object cuts. [x] +- `HT` cuts `htjmin/max`, `ihtmin/max`, `ht2min..ht4max`: no `ht` observable. [x] +- "sum" cuts `xptj/xptb/xpta/xptl`: no summed-pt observable. [x] +- lepton-pair `ptllmin/max`, neutrino-lepton `mmnl/mmnlmax`: no such combined + observable. [x] +- `ptheavy`, `ptonium`, `etaonium`: special/quarkonium cuts. [x] +- photon isolation `ptgmin`, `r0gamma`, `xn`, `epsgamma`, `isoem`, `xetamin`, + `deltaeta`: no isolation in MG7. [x] +- per-pdg cuts `pt_min_pdg`/`pt_max_pdg`/`e_*_pdg`/`eta_*_pdg`/`mxx_*_pdg` + (and the derived `*4pdg` arrays), `mxx_only_part_antipart`: no per-pdg cuts. [x] +- `cut_decays`: cut on decay products flag — no MG7 equivalent. [x] + +## 6. Matching / merging — all [x] + +`ickkw`, `xqcut`, `ktdurham`, `dparameter`, `ptlund`, `highestmult`, `ktscheme`, +`alpsfact`, `chcluster`, `pdfwgt`, `asrwgtflavor`, `clusinfo`, `auto_ptj_mjj`, +`pdgs_for_merging_cut`: MLM/CKKW(-L) merging is not implemented in MG7. + +## 7. Bias — all [x] + +`bias_module`, `bias_parameters`: no biasing in MG7. + +## 8. Engine / technical (mostly not 1:1) + +- LO run engine: `vector_size`, `nb_warp`, `vecsize_memmax`, `hard_survey`, + `job_strategy`, `survey_splitting`, `survey_nchannel_per_job`, + `refine_evt_by_job`, `second_refine_treshold`, `tmin_for_channel`, + `disable_multichannel`, `hel_recycling/filtering/splitamp/zeroamp`, + `gridrun`, `gseed`, `issgridfile`, `d`, `xmtcentral`, `mc_grouped_subproc`, + `fixed_couplings`, `global_flag`, `aloha_flag`, `small_width_treatment`. + → MG7 has its **own** engine knobs (`[vegas]`, `[generation]`, `run.devices`, + thread pools, `phasespace.mode/t_channel/flat_mode/…`). A few have loose + analogues (`disable_multichannel`↔`phasespace.mode`, survey settings↔ + `generation.survey_*`/`[vegas]`), but most are **[x] engine-specific** and + should just keep MG7 defaults, not be ported. +- Misc LO: `time_of_flight`, `allow_overshoot_events`, `bypass_check`, + `python_seed`, `lhe_version`, `boost_event`/`me_frame`/`frame_id`, + `event_norm`, `keep_log`, `custom_fcts`, `ievo_eva/evaorder/eva_xcut` → [x]. + +## 9. MG7-only (no LO source, keep default) — [mg7-only] + +`run.devices`, `run.simd_vector_size`, `run.{cpu,gpu,combine}_thread_pool_size`, +`run.output_format`, `run.verbosity`, `run.dummy_matrix_element`, +`run.gridpack_include_source`, `generation.{cpu,gpu}_batch_size`, +`generation.freeze_max_weight_after`, `generation.max_overweight_truncation`, +`generation.cut_efficiency_threshold`, `generation.max_cut_repetitions`, +all of `[vegas]`, `phasespace.{mode,t_channel,flat_mode,invariant_power, +simplified_channel_count,decays}`, all of `[madnis]`. + +--- + +## Summary / recommendation + +**Safe to port now (the converter should cover these):** +- Beams: `ebeam1/2`→`e_cm`, `lpp`→`leptonic` (common case). +- PDF: `lhaid`/`pdlabel`→`pdf` (with a label/id → name table). +- Scales: `fixed_ren_scale`, `scale`→`ren_scale`, `dsqrt_q2fact1/2`→`fact_scale1/2`, + `dynamical_scale_choice` (int→str table), `fixed_fac_scale`. +- Generation: `nevents`→`events`, `gridpack`, `bwcutoff`, `sde_strategy`, + `maxjetflavor`→jet multiparticle, `use_syst`→`systematics`. +- Cuts: pt/eta(max)/deltaR/mass/sqrt_s for jet/bottom/lepton/photon/missing. + +**Port with a warning (partial):** per-beam factorization flags, mixed/per-beam +PDF, η-min cuts, run_tag, systematics detail. + +**Cannot port — emit a clear "not supported in mg7" warning and skip:** beam +polarization, heavy ion, matching/merging (`ickkw`/`xqcut`/`ktdurham`/…), bias, +photon isolation, HT/energy/ordered/per-pdg/quarkonium cuts, helicity controls, +scale-variation extras, LO-engine technical knobs. + +**Suggested implementation shape (later):** +- `RunCardMG7.from_LO(run_card_lo)` classmethod (or `RunCardLO.to_mg7()`), + driven by an explicit mapping table (`{lo_name: (mg7_key, transform)}`) plus + cut and pdf/scale helpers. +- Collect every LO parameter that is **user_set** but falls in the [x]/[!] + buckets and report them together as "not (fully) transferable to run_card.toml", + so the retro-compatible mode is transparent about what it dropped. diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index c77012aae..6385fd6aa 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6980,6 +6980,213 @@ def remove_jet_cuts(self): if key.startswith('jet'): del self.dynamic_sections['cuts'][key] + # ------------------------------------------------------------------ + # conversion from the legacy LO run_card (see + # madgraph/various/RunCardLO_to_MG7_mapping.md for the full analysis) + # ------------------------------------------------------------------ + # LO scalar parameter -> MG7 "section.key" (same meaning, maybe renamed) + _LO_SCALAR_MAP = { + 'nevents': 'generation.events', + 'gridpack': 'run.save_gridpack', + 'fixed_ren_scale': 'beam.fixed_ren_scale', + 'scale': 'beam.ren_scale', + 'dsqrt_q2fact1': 'beam.fact_scale1', + 'dsqrt_q2fact2': 'beam.fact_scale2', + 'bwcutoff': 'phasespace.bw_cutoff', + 'use_syst': 'generation.systematics', + } + # LO dynamical_scale_choice (int) -> MG7 string + _LO_DYNSCALE_MAP = {1: 'transverse_energy', 2: 'transverse_mass', + 3: 'half_transverse_mass', 4: 'partonic_energy'} + # LO cut parameter -> (MG7 cut key, bound) + _LO_CUT_MAP = { + 'ptj': ('jet-pt', 'min'), 'ptjmax': ('jet-pt', 'max'), + 'ptb': ('bottom-pt', 'min'), 'ptbmax': ('bottom-pt', 'max'), + 'pta': ('photon-pt', 'min'), 'ptamax': ('photon-pt', 'max'), + 'ptl': ('lepton-pt', 'min'), 'ptlmax': ('lepton-pt', 'max'), + 'misset': ('missing-pt', 'min'), 'missetmax': ('missing-pt', 'max'), + 'etaj': ('jet-eta_abs', 'max'), 'etab': ('bottom-eta_abs', 'max'), + 'etaa': ('photon-eta_abs', 'max'), 'etal': ('lepton-eta_abs', 'max'), + 'drjj': ('jet-delta_r', 'min'), 'drjjmax': ('jet-delta_r', 'max'), + 'drbb': ('bottom-delta_r', 'min'), 'drbbmax': ('bottom-delta_r', 'max'), + 'drll': ('lepton-delta_r', 'min'), 'drllmax': ('lepton-delta_r', 'max'), + 'draa': ('photon-delta_r', 'min'), 'draamax': ('photon-delta_r', 'max'), + 'drbj': ('bottom-jet-delta_r', 'min'), 'drbjmax': ('bottom-jet-delta_r', 'max'), + 'draj': ('photon-jet-delta_r', 'min'), 'drajmax': ('photon-jet-delta_r', 'max'), + 'drab': ('photon-bottom-delta_r', 'min'), 'drabmax': ('photon-bottom-delta_r', 'max'), + 'drbl': ('bottom-lepton-delta_r', 'min'), 'drblmax': ('bottom-lepton-delta_r', 'max'), + 'drjl': ('jet-lepton-delta_r', 'min'), 'drjlmax': ('jet-lepton-delta_r', 'max'), + 'dral': ('photon-lepton-delta_r', 'min'), 'dralmax': ('photon-lepton-delta_r', 'max'), + 'mmjj': ('jet-mass', 'min'), 'mmjjmax': ('jet-mass', 'max'), + 'mmbb': ('bottom-mass', 'min'), 'mmbbmax': ('bottom-mass', 'max'), + 'mmaa': ('photon-mass', 'min'), 'mmaamax': ('photon-mass', 'max'), + 'mmll': ('lepton-mass', 'min'), 'mmllmax': ('lepton-mass', 'max'), + 'dsqrt_shat': ('sqrt_s', 'min'), 'dsqrt_shatmax': ('sqrt_s', 'max'), + } + # built-in LO pdlabel -> LHAPDF set name + _LO_PDF_LABEL_MAP = { + 'nn23lo': 'NNPDF23_lo_as_0130_qed', 'nn23lo1': 'NNPDF23_lo_as_0130_qed', + 'cteq6l1': 'cteq6l1', 'cteq6l': 'cteq6l1', + } + # LO lhaid -> LHAPDF set name (common cases) + _LO_LHAID_MAP = { + 230000: 'NNPDF23_lo_as_0130_qed', 247000: 'NNPDF23_lo_as_0130_qed', + 10042: 'cteq6l1', + } + # LO parameters that have no MG7 equivalent (reported when non-default). + # NB: a set literal, not set([...]) -- the class defines a set() method + # that would otherwise shadow the builtin here. + _LO_UNSUPPORTED = { + # beam polarization / heavy ion + 'polbeam1', 'polbeam2', 'nb_proton1', 'nb_proton2', 'nb_neutron1', + 'nb_neutron2', 'mass_ion1', 'mass_ion2', + # scale extras / helicity / seed + 'scalefact', 'mue_over_ref', 'mue_ref_fixed', 'fixed_extra_scale', + 'iseed', 'nhel', 'limhel', 'hel_recycling', 'hel_filtering', + 'hel_splitamp', 'hel_zeroamp', + # matching / merging + 'ickkw', 'xqcut', 'ktdurham', 'dparameter', 'ptlund', 'highestmult', + 'ktscheme', 'alpsfact', 'chcluster', 'pdfwgt', 'asrwgtflavor', + 'clusinfo', 'auto_ptj_mjj', 'pdgs_for_merging_cut', + # bias + 'bias_module', 'bias_parameters', + # unsupported cuts + 'etajmin', 'etabmin', 'etaamin', 'etalmin', + 'ej', 'eb', 'ea', 'el', 'ejmax', 'ebmax', 'eamax', 'elmax', + 'ptj1min', 'ptj1max', 'ptj2min', 'ptj2max', 'ptj3min', 'ptj3max', + 'ptj4min', 'ptj4max', 'ptl1min', 'ptl1max', 'ptl2min', 'ptl2max', + 'ptl3min', 'ptl3max', 'ptl4min', 'ptl4max', 'cutuse', + 'htjmin', 'htjmax', 'ihtmin', 'ihtmax', 'ht2min', 'ht3min', 'ht4min', + 'ht2max', 'ht3max', 'ht4max', 'xptj', 'xptb', 'xpta', 'xptl', + 'ptllmin', 'ptllmax', 'mmnl', 'mmnlmax', 'ptheavy', 'ptonium', + 'etaonium', 'ptgmin', 'r0gamma', 'xn', 'epsgamma', 'isoem', + 'xetamin', 'deltaeta', 'cut_decays', + 'pt_min_pdg', 'pt_max_pdg', 'e_min_pdg', 'e_max_pdg', 'eta_min_pdg', + 'eta_max_pdg', 'mxx_min_pdg', 'mxx_only_part_antipart', + # systematics detail / eva / frame + 'systematics_program', 'systematics_arguments', 'sys_scalefact', + 'sys_alpsfact', 'sys_matchscale', 'sys_pdf', 'sys_scalecorrelation', + 'ievo_eva', 'evaorder', 'eva_xcut', + 'boost_event', 'me_frame', 'frame_id', 'event_norm', 'lhe_version', + } + + @classmethod + def _resolve_pdf(cls, lo, dropped): + """Map the LO pdlabel/lhaid to an LHAPDF set name (or None to keep the + MG7 default, recording the reason in ``dropped``).""" + pdlabel = str(lo['pdlabel']).lower() if 'pdlabel' in lo else '' + if str(lo['pdlabel1']).lower() != str(lo['pdlabel2']).lower(): + dropped.append('pdlabel1/pdlabel2 differ (mg7 uses a single pdf)') + if pdlabel in ('none', '', 'no'): + return None + if pdlabel == 'lhapdf': + lhaid = lo['lhaid'] + if isinstance(lhaid, list): + lhaid = lhaid[0] + if lhaid in cls._LO_LHAID_MAP: + return cls._LO_LHAID_MAP[lhaid] + dropped.append('lhaid=%s (unknown LHAPDF id; keeping mg7 default pdf)' % lhaid) + return None + if pdlabel in cls._LO_PDF_LABEL_MAP: + return cls._LO_PDF_LABEL_MAP[pdlabel] + dropped.append('pdlabel=%s (unknown PDF label; keeping mg7 default pdf)' % pdlabel) + return None + + @classmethod + def from_LO(cls, lo, warn=True): + """Build a :class:`RunCardMG7` reproducing, as far as possible, the setup + of a legacy :class:`RunCardLO`. + + Returns ``(mg7_card, dropped)`` where ``dropped`` lists the LO settings + (those differing from the LO default) that could not be transferred -- + the information a retro-compatible mode needs to be transparent about + what it ignored. See RunCardLO_to_MG7_mapping.md for the full analysis.""" + mg7 = cls() + dropped = [] + lo_default = RunCardLO() + + def nondefault(name): + return name in lo and name in lo_default and lo[name] != lo_default[name] + + # --- scalar direct / renamed parameters --- + for loname, mg7key in cls._LO_SCALAR_MAP.items(): + if loname in lo: + mg7.set(mg7key, lo[loname]) + + # --- beams --- + mg7.set('beam.e_cm', float(lo['ebeam1']) + float(lo['ebeam2'])) + lpps = [lo['lpp1'], lo['lpp2']] + is_lep = lambda l: isinstance(l, int) and abs(l) in (3, 4) + # lpp 0 = fixed-energy beam with no PDF, typically a lepton collider + mg7.set('beam.leptonic', + all(l == 0 for l in lpps) or any(is_lep(l) for l in lpps)) + for i, lpp in ((1, lpps[0]), (2, lpps[1])): + if isinstance(lpp, int) and abs(lpp) not in (0, 1, 3, 4): + dropped.append('lpp%d=%s (beam type not representable in mg7)' % (i, lpp)) + elif isinstance(lpp, int) and lpp < 0: + dropped.append('lpp%d=%s (antiparticle beam: mg7 keeps the particle PDF)' % (i, lpp)) + elif lpp == 0: + dropped.append('lpp%d=0 (no-PDF fixed-energy beam: check beam.leptonic/beam.pdf)' % i) + + # --- scales --- + if 'fixed_fac_scale' in lo: + mg7.set('beam.fixed_fact_scale', lo['fixed_fac_scale']) + if lo['fixed_fac_scale1'] != lo['fixed_fac_scale2']: + dropped.append('fixed_fac_scale1/2 differ (mg7 has a single factorization flag)') + dyn = lo['dynamical_scale_choice'] + if isinstance(dyn, list): + dyn = dyn[0] + if dyn in cls._LO_DYNSCALE_MAP: + mg7.set('beam.dynamical_scale_choice', cls._LO_DYNSCALE_MAP[dyn]) + elif dyn not in (-1, 10) and nondefault('dynamical_scale_choice'): + dropped.append('dynamical_scale_choice=%s (no mg7 equivalent)' % dyn) + + # --- SDE strategy --- + sde = lo['SDE_strategy'] if 'SDE_strategy' in lo else 1 + mg7.set('phasespace.sde_strategy', + 'denominators' if int(sde) == 2 else 'diagrams') + + # --- PDF --- + pdf_name = cls._resolve_pdf(lo, dropped) + if pdf_name: + mg7.set('beam.pdf', pdf_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] + + # --- cuts (rebuild from the LO card) --- + cuts = collections.OrderedDict() + for loname, (cutkey, bound) in cls._LO_CUT_MAP.items(): + if loname not in lo: + continue + val = lo[loname] + active = (bound == 'min' and val > 0) or (bound == 'max' and val >= 0) + if active: + cuts.setdefault(cutkey, collections.OrderedDict())[bound] = float(val) + mg7.dynamic_sections['cuts'] = cuts + + # --- report the non-default settings we could not transfer --- + for name in sorted(cls._LO_UNSUPPORTED): + if not nondefault(name): + continue + val = lo[name] + # skip inactive cut sentinels (they impose no real constraint) + if isinstance(val, (int, float)) and not isinstance(val, bool): + if name.endswith('max') and (val < 0 or val >= 1e5): + continue + if name.endswith('min') and val <= 0: + continue + dropped.append('%s=%s (not supported in mg7)' % (name, val)) + + if warn and dropped: + logger.warning("Converting run_card.dat to run_card.toml: the " + "following settings could not be transferred:\n - %s", + "\n - ".join(dropped)) + return mg7, dropped + class MadLoopParam(ConfigFile): """ a class for storing/dealing with the file MadLoopParam.dat diff --git a/tests/unit_tests/various/test_banner.py b/tests/unit_tests/various/test_banner.py index b9e7dfa35..44aa6e2f2 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -1258,6 +1258,38 @@ def test_fixed_scale_and_remove_cuts(self): rc.remove_all_cut() self.assertEqual(dict(rc['cuts']), {}) + def test_from_LO_conversion(self): + """RunCardMG7.from_LO ports the supported LO settings and reports the rest""" + lo = bannermod.RunCardLO() + lo['ebeam1'] = 6500 + lo['ebeam2'] = 6500 + lo['nevents'] = 25000 + lo['ptj'] = 30 + lo['etaj'] = 4.5 + lo['dynamical_scale_choice'] = 3 + lo['SDE_strategy'] = 2 + lo['maxjetflavor'] = 5 + lo['xqcut'] = 20 # merging -> not supported + lo['polbeam1'] = 80 # polarization -> not supported + mg7, dropped = bannermod.RunCardMG7.from_LO(lo, warn=False) + # ported + self.assertEqual(mg7['beam']['e_cm'], 13000.0) + self.assertEqual(mg7['generation']['events'], 25000) + self.assertEqual(mg7['beam']['dynamical_scale_choice'], 'half_transverse_mass') + self.assertEqual(mg7['phasespace']['sde_strategy'], 'denominators') + self.assertIn(5, mg7['multiparticles']['jet']) + self.assertEqual(mg7['cuts']['jet-pt'], {'min': 30.0}) + self.assertEqual(mg7['cuts']['jet-eta_abs'], {'max': 4.5}) + # reported as not transferable + joined = ' '.join(dropped) + self.assertIn('xqcut', joined) + self.assertIn('polbeam1', joined) + # the produced card is valid TOML + import io, tomllib + buf = io.StringIO() + mg7.write(buf, template=self.template) + tomllib.loads(buf.getvalue()) + def test_defaults_and_section_access(self): """default values are accessible through nested-section views""" rc = bannermod.RunCardMG7() From d5dc5d2ff515bb0b53c16b85d9797e88bd33c3ff Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 2 Jul 2026 15:09:09 +0200 Subject: [PATCH 12/12] madspace install: drop redundant -f, use --source --yes for auto-install -f/--force only forced non-interactive prompting; -y/--yes already does that. Their only real difference was the install mode (-f always source, -y from saved/bin). Make --bin/--source take precedence over --yes when choosing the mode, remove -f/--force, and have the mg7 auto-installer request a non-interactive source build with defaults via "--source --yes". Co-Authored-By: Claude Opus 4.8 --- .../iolibs/template_files/mg7/madevent.py | 16 ++++++------- madspace/install.py | 23 ++++++++----------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/madevent.py b/madgraph/iolibs/template_files/mg7/madevent.py index 925f35c8d..7f91c1394 100644 --- a/madgraph/iolibs/template_files/mg7/madevent.py +++ b/madgraph/iolibs/template_files/mg7/madevent.py @@ -28,17 +28,17 @@ print() _install_cmd = [sys.executable, str(_MADSPACE_DIR / "install.py")] - # Propagate non-interactive context (parent -f or piped stdin) so the - # installer routes its questions through cmd.ask in force mode rather than - # blocking on input(); also expose madgraph on PYTHONPATH so the installer - # subprocess can import cmd.ask in the first place. + # Expose madgraph on PYTHONPATH so the installer subprocess can import + # cmd.ask for its prompts. _noninteractive = "-f" in sys.argv or not sys.stdin.isatty() - # When non-interactive, run the installer with defaults and keep it away - # from our stdin (which may carry the run's scripted card-editing commands); - # when interactive, let it share the terminal so the user can answer. + # When the run is non-interactive (scripted / piped), install + # non-interactively with a source build and default options (--source --yes), + # and keep the installer away from our stdin (which may carry the run's + # scripted card-editing commands); when interactive, let it share the + # terminal so the user can answer. _install_stdin = subprocess.DEVNULL if _noninteractive else None if _noninteractive: - _install_cmd.append("-f") + _install_cmd += ["--source", "--yes"] _install_env = os.environ.copy() _install_env["PYTHONPATH"] = os.pathsep.join( [str(_MG_ROOT)] + ([_install_env["PYTHONPATH"]] if _install_env.get("PYTHONPATH") else []) diff --git a/madspace/install.py b/madspace/install.py index d9a8bc78a..14ac78b37 100644 --- a/madspace/install.py +++ b/madspace/install.py @@ -50,7 +50,7 @@ # the non-interactive flag. _NONINTERACTIVE = (not sys.stdin.isatty()) or any( - a in ("-f", "--force", "-y", "--yes") for a in sys.argv[1:] + a in ("-y", "--yes") for a in sys.argv[1:] ) @@ -451,14 +451,8 @@ def main() -> None: "--yes", action="store_true", default=False, - help="Re-install non-interactively using saved settings; falls back to built-in defaults.", - ) - parser.add_argument( - "-f", - "--force", - action="store_true", - default=False, - help="Do not prompt; accept the defaults (for scripted/non-interactive use).", + help="Non-interactive: accept defaults / reuse saved settings, no prompts. " + "Combine with --source/--bin to force the install mode.", ) parser.add_argument( "--system", @@ -551,18 +545,19 @@ def main() -> None: # None = not provided by user; overridden by set_defaults below parser.set_defaults(cuda=None, hip=None, openblas=None, simd=None, build_type=None) args = parser.parse_args() - _set_noninteractive(args.force or args.yes) + _set_noninteractive(args.yes) # Load saved settings when a previous installation is present saved = load_settings() if (INSTALL_DIR / "madspace").is_dir() else {} - # Determine install mode - if args.yes: - from_source = saved.get("mode", "bin") == "source" - elif args.bin: + # Determine install mode. An explicit --bin/--source always wins; --yes + # alone reuses the saved mode (built-in default otherwise). + if args.bin: from_source = False elif args.source: from_source = True + elif args.yes: + from_source = saved.get("mode", "bin") == "source" else: print("Welcome to the MadSpace interactive installer") print()