From 669946c83398b81187c5f3f1234fd3146d64b820 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Thu, 25 Jun 2026 16:38:48 +0200 Subject: [PATCH 01/28] Draft: Implement Combine backend skeleton and object-oriented fit configuration --- dummy_fit.py | 16 +++++++++++++++ python/combine.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 dummy_fit.py create mode 100644 python/combine.py diff --git a/dummy_fit.py b/dummy_fit.py new file mode 100644 index 0000000000..201b780b93 --- /dev/null +++ b/dummy_fit.py @@ -0,0 +1,16 @@ +class Datacard: + def __init__(self): + # 1. Define channels + self.channels = ["electron_channel", "muon_channel"] + + # 2. Define processes (rates are -1 until Phase 2 is done) + self.processes = { + "ZH_signal": {"type": "signal", "rate": -1}, + "Z_background": {"type": "background", "rate": -1} + } + + # 3. Define systematics + self.systematics = { + "lumi": {"type": "lnN", "value": 1.05}, + "jet_energy_scale": {"type": "shape", "value": 1.0} + } diff --git a/python/combine.py b/python/combine.py new file mode 100644 index 0000000000..1f10053faa --- /dev/null +++ b/python/combine.py @@ -0,0 +1,51 @@ +import os +import sys +import logging +import argparse +import importlib.util + +# Use the standard FCCAnalyses logger +LOGGER = logging.getLogger('FCCAnalyses.combine') + +def generate_datacard(parser: argparse.ArgumentParser) -> None: + """ + Sub-command entry point for Combine datacard generation. + """ + args = parser.parse_args() + anapath = os.path.abspath(args.fit_script) + + LOGGER.info('Loading Combine fit script from:\\n %s', anapath) + + if not os.path.isfile(anapath): + LOGGER.error('Fit script not found!\\nAborting...') + sys.exit(3) + + # Dynamically load the user's python file + try: + spec = importlib.util.spec_from_file_location('user_fit', anapath) + user_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(user_module) + except SyntaxError as err: + LOGGER.error('Syntax error encountered in the fit script:\\n%s', err) + sys.exit(3) + + # Instantiate the user's class and validate + if not hasattr(user_module, "Datacard"): + LOGGER.error('Fit script must define a class named "Datacard"!\\nAborting...') + sys.exit(3) + + user_datacard = user_module.Datacard() + + # Check for minimal required members + if not hasattr(user_datacard, 'processes') or not hasattr(user_datacard, 'channels'): + LOGGER.error('Datacard class must have "processes" and "channels" defined!\\nAborting...') + sys.exit(3) + + LOGGER.info('Successfully validated user Datacard.') + LOGGER.info('Channels found: %s', user_datacard.channels) + LOGGER.info('Processes found: %s', list(user_datacard.processes.keys())) + + # TODO: In the next commit, insert Phase 1 text alignment and parsing engine here + # to loop over the user_datacard members and output the .txt file. + + LOGGER.info('Datacard generation skeleton complete.') From 399160d9d2abdcf1a454655ad15ee8edd37b05b3 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Thu, 25 Jun 2026 16:55:28 +0200 Subject: [PATCH 02/28] Feat: Port datacard writer and validation to backend --- dummy_fit.py | 44 ++++++++--- python/combine.py | 193 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 208 insertions(+), 29 deletions(-) diff --git a/dummy_fit.py b/dummy_fit.py index 201b780b93..0fd2cdd1d6 100644 --- a/dummy_fit.py +++ b/dummy_fit.py @@ -1,16 +1,42 @@ class Datacard: def __init__(self): - # 1. Define channels - self.channels = ["electron_channel", "muon_channel"] + # Global configuration toggles + self.autoMCStats = True - # 2. Define processes (rates are -1 until Phase 2 is done) - self.processes = { - "ZH_signal": {"type": "signal", "rate": -1}, - "Z_background": {"type": "background", "rate": -1} + # Shape file templates + self.shapes = { + "*": { + "electron_channel": "fcc_analysis_shapes.root $CHANNEL/$PROCESS", + "muon_channel": "fcc_analysis_shapes.root $CHANNEL/$PROCESS" + } } - # 3. Define systematics + # Channel-by-channel definitions + self.channels = { + "electron_channel": { + "observation": -1, + "processes": { + "ZH_signal": {"type": "signal", "rate": -1}, + "Z_background": {"type": "background", "rate": -1} + } + }, + "muon_channel": { + "observation": -1, + "processes": { + "ZH_signal": {"type": "signal", "rate": -1}, + "Z_background": {"type": "background", "rate": -1} + } + } + } + + # Systematics dictionary self.systematics = { - "lumi": {"type": "lnN", "value": 1.05}, - "jet_energy_scale": {"type": "shape", "value": 1.0} + "lumi": { + "type": "lnN", + "apply_to": {"ZH_signal": 1.05, "Z_background": 1.05} + }, + "efficiency": { + "type": "lnN", + "apply_to": {"ZH_signal": 1.02} + } } diff --git a/python/combine.py b/python/combine.py index 1f10053faa..1ba95bcea4 100644 --- a/python/combine.py +++ b/python/combine.py @@ -3,49 +3,202 @@ import logging import argparse import importlib.util +from typing import Dict, Any -# Use the standard FCCAnalyses logger +# Use the standard FCCAnalyses logging hierarchy LOGGER = logging.getLogger('FCCAnalyses.combine') +class DatacardWriter: + """Writes the structured OOP configuration out to Combine's standard text format.""" + + def __init__(self, datacard_obj): + self.datacard = datacard_obj + self.channels = getattr(datacard_obj, 'channels', {}) + self.systematics = getattr(datacard_obj, 'systematics', {}) + self.shapes = getattr(datacard_obj, 'shapes', {}) + self.autoMCStats = getattr(datacard_obj, 'autoMCStats', False) + + self.metadata = self._get_metadata() + self.col_w = self._calculate_col_width() + + def _get_metadata(self) -> Dict[str, int]: + imax = len(self.channels) + jmax = 0 + if self.channels: + first_channel = list(self.channels.values())[0] + processes = first_channel.get('processes', {}) + jmax = sum(1 for p in processes.values() if p.get('type') == 'background') + + kmax = len(self.systematics) + return {'imax': imax, 'jmax': jmax, 'kmax': kmax} + + def _calculate_col_width(self) -> int: + max_len = 10 + for ch_name, ch_data in self.channels.items(): + max_len = max(max_len, len(ch_name)) + for proc_name in ch_data.get('processes', {}).keys(): + max_len = max(max_len, len(proc_name)) + for syst_name in self.systematics.keys(): + max_len = max(max_len, len(syst_name)) + return max_len + 4 + + def generate(self, output_path: str): + with open(output_path, 'w') as f: + self._write_header(f) + self._write_shapes(f) + self._write_observation(f) + self._write_rates(f) + self._write_systematics(f) + self._write_automcstats(f) + LOGGER.info('Successfully generated production datacard: %s', output_path) + + def _write_header(self, f): + f.write(f"imax {self.metadata['imax']} number of channels\n") + f.write(f"jmax {self.metadata['jmax']} number of backgrounds\n") + f.write(f"kmax {self.metadata['kmax']} number of nuisance parameters\n") + f.write("-" * 50 + "\n") + + def _write_shapes(self, f): + if self.shapes: + for process, channels in self.shapes.items(): + for channel, mapping in channels.items(): + f.write(f"shapes {process} {channel} {mapping}\n") + f.write("-" * 50 + "\n") + + def _write_observation(self, f): + bin_names = "".join([f"{name:<{self.col_w}}" for name in self.channels.keys()]) + obs_values = "".join([f"{str(ch.get('observation', 0)):<{self.col_w}}" for ch in self.channels.values()]) + + f.write(f"{'bin':<{self.col_w}}{bin_names}\n") + f.write(f"{'observation':<{self.col_w}}{obs_values}\n") + f.write("-" * 50 + "\n") + + def _write_rates(self, f): + bin_line = [f"{'bin':<{self.col_w}}"] + process_name_line = [f"{'process':<{self.col_w}}"] + process_id_line = [f"{'process':<{self.col_w}}"] + rate_line = [f"{'rate':<{self.col_w}}"] + + for bin_name, channel_data in self.channels.items(): + processes = channel_data.get('processes', {}) + sorted_procs = sorted( + processes.items(), + key=lambda item: 0 if item[1].get('type') == 'signal' else 1 + ) + bkg_counter = 1 + for proc_name, proc_data in sorted_procs: + bin_line.append(f"{bin_name:<{self.col_w}}") + process_name_line.append(f"{proc_name:<{self.col_w}}") + if proc_data.get('type') == 'signal': + process_id_line.append(f"{'0':<{self.col_w}}") + else: + process_id_line.append(f"{str(bkg_counter):<{self.col_w}}") + bkg_counter += 1 + rate_line.append(f"{str(proc_data.get('rate', 0.0)):<{self.col_w}}") + + f.write("".join(bin_line) + "\n") + f.write("".join(process_name_line) + "\n") + f.write("".join(process_id_line) + "\n") + f.write("".join(rate_line) + "\n") + f.write("-" * 50 + "\n") + + def _write_systematics(self, f): + all_processes = [] + for channel_data in self.channels.values(): + sorted_procs = sorted( + channel_data.get('processes', {}).items(), + key=lambda item: 0 if item[1].get('type') == 'signal' else 1 + ) + all_processes.extend([proc_name for proc_name, _ in sorted_procs]) + + for syst_name, syst_data in self.systematics.items(): + syst_type = syst_data.get('type', 'lnN') + apply_to = syst_data.get('apply_to', {}) + + line_str = f"{syst_name:<{self.col_w}}{syst_type:<10}" + for proc_name in all_processes: + val = str(apply_to.get(proc_name, "-")) + line_str += f"{val:<{self.col_w}}" + + f.write(line_str + "\n") + + def _write_automcstats(self, f): + if self.autoMCStats: + f.write("-" * 50 + "\n") + f.write("* autoMCStats 0 1 1\n") + + +def validate_datacard(user_datacard) -> None: + """Validates the properties within the loaded user Datacard object.""" + channels = getattr(user_datacard, 'channels', {}) + if not channels: + LOGGER.warning("No channels defined in the datacard object.") + + for ch_name, ch_data in channels.items(): + obs = ch_data.get('observation', 0) + if not (isinstance(obs, (int, float)) and (obs >= 0 or obs == -1)): + raise ValueError(f"Validation Error: Invalid observation '{obs}' in channel '{ch_name}'. Must be >= 0 or -1.") + + processes = ch_data.get('processes', {}) + for proc_name, proc_data in processes.items(): + p_type = proc_data.get('type') + if p_type not in ['signal', 'background']: + raise ValueError(f"Validation Error: Invalid type '{p_type}' for process '{proc_name}' in channel '{ch_name}'. Must be 'signal' or 'background'.") + + rate = proc_data.get('rate', 0.0) + if not (isinstance(rate, (int, float)) and (rate >= 0 or rate == -1)): + raise ValueError(f"Validation Error: Invalid rate '{rate}' for process '{proc_name}'. Must be >= 0 or -1.") + + valid_syst_types = ['lnN', 'shape', 'gmM', 'gmN'] + systematics = getattr(user_datacard, 'systematics', {}) + for syst_name, syst_data in systematics.items(): + s_type = syst_data.get('type') + if s_type not in valid_syst_types: + raise ValueError(f"Validation Error: Invalid systematic type '{s_type}' for '{syst_name}'. Allowed: {valid_syst_types}") + + def generate_datacard(parser: argparse.ArgumentParser) -> None: - """ - Sub-command entry point for Combine datacard generation. - """ + """Sub-command engine entry point.""" args = parser.parse_args() anapath = os.path.abspath(args.fit_script) + output_path = args.output - LOGGER.info('Loading Combine fit script from:\\n %s', anapath) + LOGGER.info('Loading Combine fit script from: %s', anapath) if not os.path.isfile(anapath): - LOGGER.error('Fit script not found!\\nAborting...') + LOGGER.error('Fit script file not found! Aborting...') sys.exit(3) - # Dynamically load the user's python file try: spec = importlib.util.spec_from_file_location('user_fit', anapath) user_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(user_module) except SyntaxError as err: - LOGGER.error('Syntax error encountered in the fit script:\\n%s', err) + LOGGER.error('Syntax error encountered in the fit script:\n%s', err) sys.exit(3) - # Instantiate the user's class and validate if not hasattr(user_module, "Datacard"): - LOGGER.error('Fit script must define a class named "Datacard"!\\nAborting...') + LOGGER.error('Fit script must define a class named "Datacard"! Aborting...') sys.exit(3) user_datacard = user_module.Datacard() - # Check for minimal required members - if not hasattr(user_datacard, 'processes') or not hasattr(user_datacard, 'channels'): - LOGGER.error('Datacard class must have "processes" and "channels" defined!\\nAborting...') + try: + validate_datacard(user_datacard) + except ValueError as err: + LOGGER.error('%s\nAborting...', err) sys.exit(3) - LOGGER.info('Successfully validated user Datacard.') - LOGGER.info('Channels found: %s', user_datacard.channels) - LOGGER.info('Processes found: %s', list(user_datacard.processes.keys())) + LOGGER.info('Successfully validated user Datacard configuration data.') - # TODO: In the next commit, insert Phase 1 text alignment and parsing engine here - # to loop over the user_datacard members and output the .txt file. - - LOGGER.info('Datacard generation skeleton complete.') + # Run the generator matrix logic + writer = DatacardWriter(user_datacard) + writer.generate(output_path) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + test_parser = argparse.ArgumentParser() + test_parser.add_argument("fit_script", help="Path to the user fit script") + test_parser.add_argument("-o", "--output", default="generated_datacard.txt", help="Path to save output text datacard") + generate_datacard(test_parser) From 2b3b38d704439b055f3af0238985775f648d5161 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 26 Jun 2026 10:57:38 +0200 Subject: [PATCH 03/28] Feat: Integrate fit subcommand and parser into FCCAnalyses wrapper --- bin/fccanalysis | 3 +++ examples/dummy_fit.py | 42 ++++++++++++++++++++++++++++++++++++++++++ python/combine.py | 19 ++++--------------- python/fit.py | 26 ++++++++++++++++++++++++++ python/parsers.py | 12 ++++++++++++ 5 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 examples/dummy_fit.py create mode 100644 python/fit.py diff --git a/bin/fccanalysis b/bin/fccanalysis index ed2ff11913..82c90d7e9a 100755 --- a/bin/fccanalysis +++ b/bin/fccanalysis @@ -16,6 +16,7 @@ from run_analysis import run from run_final_analysis import run_final from do_plots import do_plots from do_combine import do_combine +from fit import run_fit # _____________________________________________________________________________ @@ -125,6 +126,8 @@ def main(): do_plots(parser) elif args.command == 'combine': do_combine(parser) + elif args.command == 'fit': + run_fit(parser) else: run(parser) diff --git a/examples/dummy_fit.py b/examples/dummy_fit.py new file mode 100644 index 0000000000..0fd2cdd1d6 --- /dev/null +++ b/examples/dummy_fit.py @@ -0,0 +1,42 @@ +class Datacard: + def __init__(self): + # Global configuration toggles + self.autoMCStats = True + + # Shape file templates + self.shapes = { + "*": { + "electron_channel": "fcc_analysis_shapes.root $CHANNEL/$PROCESS", + "muon_channel": "fcc_analysis_shapes.root $CHANNEL/$PROCESS" + } + } + + # Channel-by-channel definitions + self.channels = { + "electron_channel": { + "observation": -1, + "processes": { + "ZH_signal": {"type": "signal", "rate": -1}, + "Z_background": {"type": "background", "rate": -1} + } + }, + "muon_channel": { + "observation": -1, + "processes": { + "ZH_signal": {"type": "signal", "rate": -1}, + "Z_background": {"type": "background", "rate": -1} + } + } + } + + # Systematics dictionary + self.systematics = { + "lumi": { + "type": "lnN", + "apply_to": {"ZH_signal": 1.05, "Z_background": 1.05} + }, + "efficiency": { + "type": "lnN", + "apply_to": {"ZH_signal": 1.02} + } + } diff --git a/python/combine.py b/python/combine.py index 1ba95bcea4..926d88e8f3 100644 --- a/python/combine.py +++ b/python/combine.py @@ -157,18 +157,15 @@ def validate_datacard(user_datacard) -> None: raise ValueError(f"Validation Error: Invalid systematic type '{s_type}' for '{syst_name}'. Allowed: {valid_syst_types}") -def generate_datacard(parser: argparse.ArgumentParser) -> None: - """Sub-command engine entry point.""" - args = parser.parse_args() - anapath = os.path.abspath(args.fit_script) - output_path = args.output +def generate_datacard(anapath: str, output_path: str) -> None: + """Sub-command engine execution block.""" LOGGER.info('Loading Combine fit script from: %s', anapath) - + if not os.path.isfile(anapath): LOGGER.error('Fit script file not found! Aborting...') sys.exit(3) - + try: spec = importlib.util.spec_from_file_location('user_fit', anapath) user_module = importlib.util.module_from_spec(spec) @@ -194,11 +191,3 @@ def generate_datacard(parser: argparse.ArgumentParser) -> None: # Run the generator matrix logic writer = DatacardWriter(user_datacard) writer.generate(output_path) - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - test_parser = argparse.ArgumentParser() - test_parser.add_argument("fit_script", help="Path to the user fit script") - test_parser.add_argument("-o", "--output", default="generated_datacard.txt", help="Path to save output text datacard") - generate_datacard(test_parser) diff --git a/python/fit.py b/python/fit.py new file mode 100644 index 0000000000..91fddd0cb5 --- /dev/null +++ b/python/fit.py @@ -0,0 +1,26 @@ +import os +import sys +import logging +from combine import generate_datacard + +LOGGER = logging.getLogger('FCCAnalyses.fit') + +def run_fit(parser): + """ + Entry point for the 'fccanalysis fit' sub-command. + """ + args, _ = parser.parse_known_args() + + if args.command != 'fit': + LOGGER.error('Wrong sub-command!\nAborting...') + sys.exit(3) + + if not os.path.isfile(args.script_path): + LOGGER.error('Fit script "%s" not found!\nAborting...', args.script_path) + sys.exit(3) + + # Convert to absolute path and pass to Combine engine + anapath = os.path.abspath(args.script_path) + output_path = args.output + + generate_datacard(anapath, output_path) diff --git a/python/parsers.py b/python/parsers.py index ad0c4041bf..b3d365d63a 100644 --- a/python/parsers.py +++ b/python/parsers.py @@ -301,6 +301,14 @@ def setup_run_parser_combine(parser): ''' parser.add_argument('script_path', help="path to the combine script") +# _____________________________________________________________________________ +def setup_run_parser_fit(parser): + ''' + Define command line arguments for the fit sub-command. + ''' + parser.add_argument('script_path', help="path to the object-oriented fit script") + parser.add_argument('-o', '--output', type=str, default="generated_datacard.txt", + help="path to save the output text datacard") # _____________________________________________________________________________ def setup_subparsers(topparser): @@ -333,6 +341,9 @@ def setup_subparsers(topparser): parser_run_combine = topparser.add_parser( 'combine', help="prepare combine cards to run basic template fits") + parser_run_fit = topparser.add_parser( + 'fit', + help="generate combine datacards using object-oriented Python config") # Register sub-parsers setup_build_parser(parser_build) @@ -343,3 +354,4 @@ def setup_subparsers(topparser): setup_run_parser_final(parser_run_final) setup_run_parser_plots(parser_run_plots) setup_run_parser_combine(parser_run_combine) + setup_run_parser_fit(parser_run_fit) From 70ea34afc402150bddfe233d1059a3ba610d6ef5 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 26 Jun 2026 11:57:53 +0200 Subject: [PATCH 04/28] Refactor: Modularize fit command to support multiple backends via --backend flag --- python/fit.py | 39 +++++++++++++++++++++------------------ python/parsers.py | 2 ++ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/python/fit.py b/python/fit.py index 91fddd0cb5..faaecc04cd 100644 --- a/python/fit.py +++ b/python/fit.py @@ -1,26 +1,29 @@ import os import sys import logging -from combine import generate_datacard +import argparse LOGGER = logging.getLogger('FCCAnalyses.fit') -def run_fit(parser): - """ - Entry point for the 'fccanalysis fit' sub-command. - """ - args, _ = parser.parse_known_args() - - if args.command != 'fit': - LOGGER.error('Wrong sub-command!\nAborting...') - sys.exit(3) - - if not os.path.isfile(args.script_path): - LOGGER.error('Fit script "%s" not found!\nAborting...', args.script_path) - sys.exit(3) - - # Convert to absolute path and pass to Combine engine +def run_fit(parser: argparse.ArgumentParser) -> None: + """Sub-command entry point for object-oriented fitting configurations.""" + args = parser.parse_args() + anapath = os.path.abspath(args.script_path) output_path = args.output - - generate_datacard(anapath, output_path) + backend = args.backend.lower() + + LOGGER.info('Steering fit configuration towards the "%s" backend...', backend) + + if backend == 'combine': + # Lazy import to keep dependencies clean + from combine import generate_datacard + generate_datacard(anapath, output_path) + + elif backend == 'pyhf': + LOGGER.error('Backend "pyhf" is planned but not yet implemented! Aborting...') + sys.exit(5) + + else: + LOGGER.error('Unsupported fitting backend: %s. Aborting...', backend) + sys.exit(4) diff --git a/python/parsers.py b/python/parsers.py index b3d365d63a..0935fa4022 100644 --- a/python/parsers.py +++ b/python/parsers.py @@ -309,6 +309,8 @@ def setup_run_parser_fit(parser): parser.add_argument('script_path', help="path to the object-oriented fit script") parser.add_argument('-o', '--output', type=str, default="generated_datacard.txt", help="path to save the output text datacard") + parser.add_argument('--backend', type=str, choices=['combine', 'pyhf'], default='combine', + help="fitting backend infrastructure to target") # _____________________________________________________________________________ def setup_subparsers(topparser): From 03e42c123a1793d4a95c77702602dee2c0c47889 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 26 Jun 2026 12:38:01 +0200 Subject: [PATCH 05/28] Fix systematics matrix column alignment and add multi-channel ZH physics example --- examples/fcc_ee_zh_mumu_bb.py | 80 +++++++++++++++++++++++++++++++++++ python/combine.py | 46 ++++++++++++-------- 2 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 examples/fcc_ee_zh_mumu_bb.py diff --git a/examples/fcc_ee_zh_mumu_bb.py b/examples/fcc_ee_zh_mumu_bb.py new file mode 100644 index 0000000000..0174ecb5a0 --- /dev/null +++ b/examples/fcc_ee_zh_mumu_bb.py @@ -0,0 +1,80 @@ +class Datacard: + def __init__(self): + # 1. Global framework configurations + self.autoMCStats = True + + # 2. Path templates for the input shape histograms + # Maps the processes to the ROOT file containing the physical templates + self.shapes = { + "*": { + "mumu_bjets_channel": "fcc_ee_zh_shapes.root $CHANNEL/$PROCESS", + "mumu_inter_channel": "fcc_ee_zh_shapes.root $CHANNEL/$PROCESS" + } + } + + # 3. Channel definitions based on event selection categories + # Category 1: High purity b-tagged category + # Category 2: Intermediate/loose b-tagged category (to constrain backgrounds) + self.channels = { + "mumu_bjets_channel": { + "observation": -1, # -1 instructs Combine to use asymptotic or toy datasets + "processes": { + "ZH_signal": {"type": "signal", "rate": -1}, + "ZZ_bkg": {"type": "background", "rate": -1}, + "Zjets_bkg": {"type": "background", "rate": -1} + } + }, + "mumu_inter_channel": { + "observation": -1, + "processes": { + "ZH_signal": {"type": "signal", "rate": -1}, + "ZZ_bkg": {"type": "background", "rate": -1}, + "Zjets_bkg": {"type": "background", "rate": -1} + } + } + } + + # 4. Systematics Matrix + # Models how uncertainties affect each process in each distinct channel + self.systematics = { + # Integrated luminosity precision at FCC-ee (highly precise, ~0.1% to 0.5%) + "lumi_FCC": { + "type": "lnN", + "apply_to": { + "ZH_signal": 1.005, + "ZZ_bkg": 1.005, + "Zjets_bkg": 1.005 + } + }, + # Muon tracking and Identification efficiency uncertainty + "muon_eff": { + "type": "lnN", + "apply_to": { + "ZH_signal": 1.01, + "ZZ_bkg": 1.01, + "Zjets_bkg": 1.01 + } + }, + # B-tagging efficiency uncertainty (highly correlated with signal) + "btag_eff": { + "type": "lnN", + "apply_to": { + "ZH_signal": {"mumu_bjets_channel": 1.03, "mumu_inter_channel": 0.98}, + "ZZ_bkg": {"mumu_bjets_channel": 1.03, "mumu_inter_channel": 0.98} + } + }, + # Mistag rate uncertainty for light/charm jets leaking into the b-jet channel + "mistag_light": { + "type": "lnN", + "apply_to": { + "Zjets_bkg": {"mumu_bjets_channel": 1.10, "mumu_inter_channel": 1.04} + } + }, + # Theoretical cross-section uncertainty for electroweak backgrounds + "theory_ZZ_xsec": { + "type": "lnN", + "apply_to": { + "ZZ_bkg": 1.04 + } + } + } diff --git a/python/combine.py b/python/combine.py index 926d88e8f3..fd12b04d65 100644 --- a/python/combine.py +++ b/python/combine.py @@ -103,24 +103,34 @@ def _write_rates(self, f): f.write("-" * 50 + "\n") def _write_systematics(self, f): - all_processes = [] - for channel_data in self.channels.values(): - sorted_procs = sorted( - channel_data.get('processes', {}).items(), - key=lambda item: 0 if item[1].get('type') == 'signal' else 1 - ) - all_processes.extend([proc_name for proc_name, _ in sorted_procs]) - - for syst_name, syst_data in self.systematics.items(): - syst_type = syst_data.get('type', 'lnN') - apply_to = syst_data.get('apply_to', {}) - - line_str = f"{syst_name:<{self.col_w}}{syst_type:<10}" - for proc_name in all_processes: - val = str(apply_to.get(proc_name, "-")) - line_str += f"{val:<{self.col_w}}" - - f.write(line_str + "\n") + for syst_name, syst_info in self.datacard.systematics.items(): + syst_type = syst_info.get("type", "lnN") + apply_to = syst_info.get("apply_to", {}) + + # Start each row with the systematic name and its type + row_cells = [syst_name, syst_type] + + # Loop over channels + for ch_name, ch_info in self.datacard.channels.items(): + sorted_procs = sorted( + ch_info.get('processes', {}).items(), + key=lambda item: 0 if item[1].get('type') == 'signal' else 1 + ) + + for proc_name, _ in sorted_procs: + # Check if this systematic applies to the current process + if proc_name in apply_to: + val = apply_to[proc_name] + + # If it's a channel-dependent dictionary, extract the specific channel value + if isinstance(val, dict): + val = val.get(ch_name, "-") + + row_cells.append(str(val)) + else: + row_cells.append("-") + + f.write("\t".join(row_cells) + "\n") def _write_automcstats(self, f): if self.autoMCStats: From 8a1b92c4a52d1942ec4e8594875f84d5cfb7f369 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Mon, 29 Jun 2026 11:22:57 +0200 Subject: [PATCH 06/28] feat: add -e/--execute and -l flags for direct combine fitting --- dummy_fit.py | 42 ------------------------------------------ python/fit.py | 22 +++++++++++++++++----- python/parsers.py | 4 +++- 3 files changed, 20 insertions(+), 48 deletions(-) delete mode 100644 dummy_fit.py diff --git a/dummy_fit.py b/dummy_fit.py deleted file mode 100644 index 0fd2cdd1d6..0000000000 --- a/dummy_fit.py +++ /dev/null @@ -1,42 +0,0 @@ -class Datacard: - def __init__(self): - # Global configuration toggles - self.autoMCStats = True - - # Shape file templates - self.shapes = { - "*": { - "electron_channel": "fcc_analysis_shapes.root $CHANNEL/$PROCESS", - "muon_channel": "fcc_analysis_shapes.root $CHANNEL/$PROCESS" - } - } - - # Channel-by-channel definitions - self.channels = { - "electron_channel": { - "observation": -1, - "processes": { - "ZH_signal": {"type": "signal", "rate": -1}, - "Z_background": {"type": "background", "rate": -1} - } - }, - "muon_channel": { - "observation": -1, - "processes": { - "ZH_signal": {"type": "signal", "rate": -1}, - "Z_background": {"type": "background", "rate": -1} - } - } - } - - # Systematics dictionary - self.systematics = { - "lumi": { - "type": "lnN", - "apply_to": {"ZH_signal": 1.05, "Z_background": 1.05} - }, - "efficiency": { - "type": "lnN", - "apply_to": {"ZH_signal": 1.02} - } - } diff --git a/python/fit.py b/python/fit.py index faaecc04cd..9fde8e7194 100644 --- a/python/fit.py +++ b/python/fit.py @@ -2,6 +2,8 @@ import sys import logging import argparse +import subprocess +import shutil LOGGER = logging.getLogger('FCCAnalyses.fit') @@ -16,14 +18,24 @@ def run_fit(parser: argparse.ArgumentParser) -> None: LOGGER.info('Steering fit configuration towards the "%s" backend...', backend) if backend == 'combine': - # Lazy import to keep dependencies clean from combine import generate_datacard generate_datacard(anapath, output_path) + # If the user requested live execution + if args.execute: + if shutil.which('combine') is None: + LOGGER.error('The "combine" command-line tool cannot be found in your current environment path!\n' + 'Please ensure you have sourced your Combine workspace before running execution.') + sys.exit(6) + + LOGGER.info('Launching Combine statistical engine execution on: %s', output_path) + try: + # Executes the single-line combine limit calculation command + subprocess.run(['combine', '-M', 'AsymptoticLimits', output_path], check=True) + except subprocess.CalledProcessError as e: + LOGGER.error('Combine statistical fitting execution failed! Error code: %s', e.returncode) + sys.exit(7) + elif backend == 'pyhf': LOGGER.error('Backend "pyhf" is planned but not yet implemented! Aborting...') sys.exit(5) - - else: - LOGGER.error('Unsupported fitting backend: %s. Aborting...', backend) - sys.exit(4) diff --git a/python/parsers.py b/python/parsers.py index 0935fa4022..e8d0b8da00 100644 --- a/python/parsers.py +++ b/python/parsers.py @@ -309,8 +309,10 @@ def setup_run_parser_fit(parser): parser.add_argument('script_path', help="path to the object-oriented fit script") parser.add_argument('-o', '--output', type=str, default="generated_datacard.txt", help="path to save the output text datacard") - parser.add_argument('--backend', type=str, choices=['combine', 'pyhf'], default='combine', + parser.add_argument('-b', '--backend', type=str, choices=['combine', 'pyhf'], default='combine', help="fitting backend infrastructure to target") + parser.add_argument('-e', '--execute', action='store_true', + help="automatically execute the fit calculation backend after generating the model") # _____________________________________________________________________________ def setup_subparsers(topparser): From 4f1369bb737ff1db0849eccfcae703904c3bf328 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Thu, 2 Jul 2026 11:38:09 +0200 Subject: [PATCH 07/28] chore: remove redundant dummy_fit.py example template --- examples/dummy_fit.py | 42 ------------------------------------------ 1 file changed, 42 deletions(-) delete mode 100644 examples/dummy_fit.py diff --git a/examples/dummy_fit.py b/examples/dummy_fit.py deleted file mode 100644 index 0fd2cdd1d6..0000000000 --- a/examples/dummy_fit.py +++ /dev/null @@ -1,42 +0,0 @@ -class Datacard: - def __init__(self): - # Global configuration toggles - self.autoMCStats = True - - # Shape file templates - self.shapes = { - "*": { - "electron_channel": "fcc_analysis_shapes.root $CHANNEL/$PROCESS", - "muon_channel": "fcc_analysis_shapes.root $CHANNEL/$PROCESS" - } - } - - # Channel-by-channel definitions - self.channels = { - "electron_channel": { - "observation": -1, - "processes": { - "ZH_signal": {"type": "signal", "rate": -1}, - "Z_background": {"type": "background", "rate": -1} - } - }, - "muon_channel": { - "observation": -1, - "processes": { - "ZH_signal": {"type": "signal", "rate": -1}, - "Z_background": {"type": "background", "rate": -1} - } - } - } - - # Systematics dictionary - self.systematics = { - "lumi": { - "type": "lnN", - "apply_to": {"ZH_signal": 1.05, "Z_background": 1.05} - }, - "efficiency": { - "type": "lnN", - "apply_to": {"ZH_signal": 1.02} - } - } From 624a38b34aa9f37292462be0b4973ddd2fa3ecea Mon Sep 17 00:00:00 2001 From: soniyogi Date: Mon, 6 Jul 2026 11:56:30 +0200 Subject: [PATCH 08/28] feat: implement double-dash argument forwarding to wrapped tools --- python/fit.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/python/fit.py b/python/fit.py index 9fde8e7194..922fcee4c3 100644 --- a/python/fit.py +++ b/python/fit.py @@ -9,8 +9,10 @@ def run_fit(parser: argparse.ArgumentParser) -> None: """Sub-command entry point for object-oriented fitting configurations.""" - args = parser.parse_args() + # parse_known_args splits recognized flags from extra forwarding flags + args, tool_args = parser.parse_known_args() + anapath = os.path.abspath(args.script_path) output_path = args.output backend = args.backend.lower() @@ -20,22 +22,22 @@ def run_fit(parser: argparse.ArgumentParser) -> None: if backend == 'combine': from combine import generate_datacard generate_datacard(anapath, output_path) - - # If the user requested live execution + if args.execute: if shutil.which('combine') is None: - LOGGER.error('The "combine" command-line tool cannot be found in your current environment path!\n' - 'Please ensure you have sourced your Combine workspace before running execution.') + LOGGER.error('The "combine" command-line tool cannot be found...') sys.exit(6) - + LOGGER.info('Launching Combine statistical engine execution on: %s', output_path) try: - # Executes the single-line combine limit calculation command - subprocess.run(['combine', '-M', 'AsymptoticLimits', output_path], check=True) + # 1. Start with the clean, default framework command + base_command = ['combine', '-M', 'AsymptoticLimits', output_path] + + # 2. Append whatever the user passed after the '--' + full_command = base_command + tool_args + + subprocess.run(full_command, check=True) + except subprocess.CalledProcessError as e: - LOGGER.error('Combine statistical fitting execution failed! Error code: %s', e.returncode) + LOGGER.error('Combine statistical fitting execution failed!') sys.exit(7) - - elif backend == 'pyhf': - LOGGER.error('Backend "pyhf" is planned but not yet implemented! Aborting...') - sys.exit(5) From 04c784f209ba4513a4f07344cbc6c8655b30e583 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Mon, 6 Jul 2026 12:13:46 +0200 Subject: [PATCH 09/28] feat: strip literal double-dash from tool arguments before execution --- python/fit.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/fit.py b/python/fit.py index 922fcee4c3..c1e2403375 100644 --- a/python/fit.py +++ b/python/fit.py @@ -34,6 +34,8 @@ def run_fit(parser: argparse.ArgumentParser) -> None: base_command = ['combine', '-M', 'AsymptoticLimits', output_path] # 2. Append whatever the user passed after the '--' + if tool_args and tool_args[0] == '--': + tool_args = tool_args[1:] full_command = base_command + tool_args subprocess.run(full_command, check=True) From 6b9178391092321495286ddd8457a6473dc4ff90 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Mon, 6 Jul 2026 12:48:33 +0200 Subject: [PATCH 10/28] feat: implement shape systematics validation and update example template --- examples/fcc_ee_zh_mumu_bb.py | 15 +++++++--- python/combine.py | 52 ++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/examples/fcc_ee_zh_mumu_bb.py b/examples/fcc_ee_zh_mumu_bb.py index 0174ecb5a0..060581a594 100644 --- a/examples/fcc_ee_zh_mumu_bb.py +++ b/examples/fcc_ee_zh_mumu_bb.py @@ -1,14 +1,13 @@ class Datacard: def __init__(self): # 1. Global framework configurations - self.autoMCStats = True + self.autoMCStats = False # 2. Path templates for the input shape histograms - # Maps the processes to the ROOT file containing the physical templates self.shapes = { "*": { - "mumu_bjets_channel": "fcc_ee_zh_shapes.root $CHANNEL/$PROCESS", - "mumu_inter_channel": "fcc_ee_zh_shapes.root $CHANNEL/$PROCESS" + "mumu_bjets_channel": "fcc_ee_zh_shapes.root $CHANNEL/$PROCESS $CHANNEL/$PROCESS_$SYSTEMATIC", + "mumu_inter_channel": "fcc_ee_zh_shapes.root $CHANNEL/$PROCESS $CHANNEL/$PROCESS_$SYSTEMATIC" } } @@ -76,5 +75,13 @@ def __init__(self): "apply_to": { "ZZ_bkg": 1.04 } + }, + # Recoil mass resolution shape systematic + "recoil_res": { + "type": "shape", + "apply_to": { + "ZH_signal": 1.0, + "ZZ_bkg": 1.0 + } } } diff --git a/python/combine.py b/python/combine.py index fd12b04d65..0a4e27c09c 100644 --- a/python/combine.py +++ b/python/combine.py @@ -139,11 +139,14 @@ def _write_automcstats(self, f): def validate_datacard(user_datacard) -> None: - """Validates the properties within the loaded user Datacard object.""" + """Validates properties and verifies shape histogram existence in ROOT files.""" + import ROOT + channels = getattr(user_datacard, 'channels', {}) if not channels: LOGGER.warning("No channels defined in the datacard object.") + # 1. Standard structural and rate checks for ch_name, ch_data in channels.items(): obs = ch_data.get('observation', 0) if not (isinstance(obs, (int, float)) and (obs >= 0 or obs == -1)): @@ -161,11 +164,58 @@ def validate_datacard(user_datacard) -> None: valid_syst_types = ['lnN', 'shape', 'gmM', 'gmN'] systematics = getattr(user_datacard, 'systematics', {}) + shapes_config = getattr(user_datacard, 'shapes', {}) + + # 2. Advanced Shape Systematics Check for syst_name, syst_data in systematics.items(): s_type = syst_data.get('type') if s_type not in valid_syst_types: raise ValueError(f"Validation Error: Invalid systematic type '{s_type}' for '{syst_name}'. Allowed: {valid_syst_types}") + # If it's a shape systematic, verify the physical histograms exist + if s_type == 'shape': + apply_to = syst_data.get('apply_to', {}) + + for ch_name, ch_info in channels.items(): + # Determine what the shape mapping rule is for this channel + # Handles wildcard '*' or specific process rules + mapping_rule = shapes_config.get('*', {}).get(ch_name) or shapes_config.get(proc_name, {}).get(ch_name) + + if not mapping_rule: + continue + + # Extract the ROOT file path (assumes space-separated path and inner structure mapping) + root_file_path = mapping_rule.split()[0] + + if not os.path.isfile(root_file_path): + LOGGER.warning("Shape file '%s' not created yet or using relative build path. Skipping deep object check.", root_file_path) + continue + + # Open the file via PyROOT to peek inside + r_file = ROOT.TFile.Open(root_file_path, "READ") + if not r_file or r_file.IsZombie(): + raise ValueError(f"Validation Error: Failed to open shape ROOT file: {root_file_path}") + + for proc_name in ch_info.get('processes', {}).keys(): + if proc_name in apply_to and str(apply_to[proc_name]) != '-': + + # Resolve the inner path template (e.g., $CHANNEL/$PROCESS -> mumu_bjets_channel/ZH_signal) + base_path = mapping_rule.split()[1].replace('$CHANNEL', ch_name).replace('$PROCESS', proc_name) + + # Combine expects clones with "Up" and "Down" appended to the nominal path/name + for variation in ['Up', 'Down']: + target_hist_path = f"{base_path}_{syst_name}{variation}" + hist = r_file.Get(target_hist_path) + + if not hist or not hist.InheritsFrom("TH1"): + r_file.Close() + raise ValueError( + f"Validation Error: Missing required shape histogram!\n" + f" File: {root_file_path}\n" + f" Expected Path: {target_hist_path}\n" + f" Reason: Systematic '{syst_name}' is declared as 'shape' for '{proc_name}' in '{ch_name}'." + ) + r_file.Close() def generate_datacard(anapath: str, output_path: str) -> None: """Sub-command engine execution block.""" From 45326875733ba3616ab23d423801dcbd354daed7 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Mon, 6 Jul 2026 13:03:52 +0200 Subject: [PATCH 11/28] fix: resolve argument conflicts when forwarding custom combine methods --- python/fit.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/python/fit.py b/python/fit.py index c1e2403375..d54849cd4c 100644 --- a/python/fit.py +++ b/python/fit.py @@ -30,14 +30,20 @@ def run_fit(parser: argparse.ArgumentParser) -> None: LOGGER.info('Launching Combine statistical engine execution on: %s', output_path) try: - # 1. Start with the clean, default framework command - base_command = ['combine', '-M', 'AsymptoticLimits', output_path] - - # 2. Append whatever the user passed after the '--' + # 1. Strip the '--' separator if present if tool_args and tool_args[0] == '--': tool_args = tool_args[1:] - full_command = base_command + tool_args + # 2. Check if the user explicitly provided a custom method + if '-M' in tool_args or '--method' in tool_args: + # Drop the default AsymptoticLimits so they don't clash + base_command = ['combine', output_path] + else: + # Fallback to the default minimizer + base_command = ['combine', '-M', 'AsymptoticLimits', output_path] + + # 3. Combine and execute + full_command = base_command + tool_args subprocess.run(full_command, check=True) except subprocess.CalledProcessError as e: From f2509007a8ebb44806b4e172c4feec1f7b2a1fe6 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Thu, 16 Jul 2026 12:13:06 +0200 Subject: [PATCH 12/28] add CLI usage examples to example script and parser help --- examples/fcc_ee_zh_mumu_bb.py | 13 +++++++++++++ python/parsers.py | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/examples/fcc_ee_zh_mumu_bb.py b/examples/fcc_ee_zh_mumu_bb.py index 060581a594..811c7a79ab 100644 --- a/examples/fcc_ee_zh_mumu_bb.py +++ b/examples/fcc_ee_zh_mumu_bb.py @@ -1,3 +1,16 @@ +"""Example configuration script for FCC-ee ZH angular/mass fitting. + +Usage Examples: + 1. Generate the text datacard only: + fccanalysis fit examples/fcc_ee_zh_mumu_bb.py -o outputs/datacard.txt + + 2. Generate datacard and run the default AsymptoticLimits engine: + fccanalysis fit examples/fcc_ee_zh_mumu_bb.py -o outputs/datacard.txt -e + + 3. Override the default engine with custom options (e.g. FitDiagnostics): + fccanalysis fit examples/fcc_ee_zh_mumu_bb.py -o outputs/datacard.txt -e -- -M FitDiagnostics +""" + class Datacard: def __init__(self): # 1. Global framework configurations diff --git a/python/parsers.py b/python/parsers.py index e8d0b8da00..af5886dc98 100644 --- a/python/parsers.py +++ b/python/parsers.py @@ -347,7 +347,8 @@ def setup_subparsers(topparser): help="prepare combine cards to run basic template fits") parser_run_fit = topparser.add_parser( 'fit', - help="generate combine datacards using object-oriented Python config") + help="generate combine datacards using object-oriented Python config." + "Use '-- [args]' to forward custom flags directly to the backend engine.") # Register sub-parsers setup_build_parser(parser_build) From 050e0bfef8e782b31a3dba0c3dfde6f0fbc2c8ef Mon Sep 17 00:00:00 2001 From: soniyogi Date: Thu, 16 Jul 2026 12:18:55 +0200 Subject: [PATCH 13/28] rename configuration validation to sanitization --- python/combine.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/combine.py b/python/combine.py index 0a4e27c09c..97dd197cf6 100644 --- a/python/combine.py +++ b/python/combine.py @@ -138,8 +138,8 @@ def _write_automcstats(self, f): f.write("* autoMCStats 0 1 1\n") -def validate_datacard(user_datacard) -> None: - """Validates properties and verifies shape histogram existence in ROOT files.""" +def sanitize_and_validate_config(user_datacard) -> None: + """Validates properties and verifies shape histogram existence in ROOT files before backend execution.""" import ROOT channels = getattr(user_datacard, 'channels', {}) @@ -150,7 +150,7 @@ def validate_datacard(user_datacard) -> None: for ch_name, ch_data in channels.items(): obs = ch_data.get('observation', 0) if not (isinstance(obs, (int, float)) and (obs >= 0 or obs == -1)): - raise ValueError(f"Validation Error: Invalid observation '{obs}' in channel '{ch_name}'. Must be >= 0 or -1.") + raise ValueError(f"Sanitization Error: Invalid observation '{obs}' in channel '{ch_name}'. Must be >= 0 or -1.") processes = ch_data.get('processes', {}) for proc_name, proc_data in processes.items(): @@ -241,12 +241,12 @@ def generate_datacard(anapath: str, output_path: str) -> None: user_datacard = user_module.Datacard() try: - validate_datacard(user_datacard) + sanitize_and_validate_config(user_datacard) except ValueError as err: LOGGER.error('%s\nAborting...', err) sys.exit(3) - LOGGER.info('Successfully validated user Datacard configuration data.') + LOGGER.info('Successfully sanitized and verified user input configuration.') # Run the generator matrix logic writer = DatacardWriter(user_datacard) From c32f37c8c7f360d3c0b232012e99545098d921b9 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Thu, 16 Jul 2026 12:27:43 +0200 Subject: [PATCH 14/28] handle keyboard interrupt to ensure termination on Ctrl+C --- python/fit.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/python/fit.py b/python/fit.py index d54849cd4c..ba91c7112d 100644 --- a/python/fit.py +++ b/python/fit.py @@ -49,3 +49,8 @@ def run_fit(parser: argparse.ArgumentParser) -> None: except subprocess.CalledProcessError as e: LOGGER.error('Combine statistical fitting execution failed!') sys.exit(7) + + except KeyboardInterrupt: + # Catch Ctrl+C, log a clean exit status message, and exit with code 0 + LOGGER.info('Fit execution interrupted by user (Ctrl+C). Terminating...') + sys.exit(0) From d7a9c59cb57f152a4086b11ce3b91b1fceff14de Mon Sep 17 00:00:00 2001 From: soniyogi Date: Thu, 16 Jul 2026 15:12:40 +0200 Subject: [PATCH 15/28] feat: organize combine output files dynamically --- python/fit.py | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/python/fit.py b/python/fit.py index ba91c7112d..7fe8912560 100644 --- a/python/fit.py +++ b/python/fit.py @@ -4,13 +4,13 @@ import argparse import subprocess import shutil +import glob # <--- Added for tracking output ROOT files LOGGER = logging.getLogger('FCCAnalyses.fit') def run_fit(parser: argparse.ArgumentParser) -> None: """Sub-command entry point for object-oriented fitting configurations.""" - # parse_known_args splits recognized flags from extra forwarding flags args, tool_args = parser.parse_known_args() anapath = os.path.abspath(args.script_path) @@ -36,21 +36,38 @@ def run_fit(parser: argparse.ArgumentParser) -> None: # 2. Check if the user explicitly provided a custom method if '-M' in tool_args or '--method' in tool_args: - # Drop the default AsymptoticLimits so they don't clash base_command = ['combine', output_path] else: - # Fallback to the default minimizer base_command = ['combine', '-M', 'AsymptoticLimits', output_path] - - # 3. Combine and execute + + # 3. Execute Combine from the current directory (resolves relative shape files) full_command = base_command + tool_args subprocess.run(full_command, check=True) + # 4. Resolve the output directory and move generated output files there + output_dir = os.path.dirname(os.path.abspath(output_path)) + os.makedirs(output_dir, exist_ok=True) + + output_patterns = ['higgsCombine*.root', 'fitDiagnostics*.root', 'combine_logger.out'] + moved_count = 0 + + for pattern in output_patterns: + for filepath in glob.glob(pattern): + dest_path = os.path.join(output_dir, os.path.basename(filepath)) + if os.path.exists(dest_path): + os.remove(dest_path) + shutil.move(filepath, output_dir) + moved_count += 1 + + if moved_count > 0: + # Convert absolute path to a clean relative path for the CLI output + rel_output_dir = os.path.relpath(output_dir) + LOGGER.info("Successfully organized %d fit artifact(s) in: %s/", moved_count, rel_output_dir) + except subprocess.CalledProcessError as e: LOGGER.error('Combine statistical fitting execution failed!') sys.exit(7) - + except KeyboardInterrupt: - # Catch Ctrl+C, log a clean exit status message, and exit with code 0 - LOGGER.info('Fit execution interrupted by user (Ctrl+C). Terminating...') + LOGGER.info('Fit execution interrupted by user (Ctrl+C). Terminating cleanly...') sys.exit(0) From 55d8c31a9405f4cd0b11ec0ccaf7e3b3807060fb Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 17 Jul 2026 07:56:27 +0200 Subject: [PATCH 16/28] optimize shape sanitization via bulk pre-loading and TFile disassociation --- python/combine.py | 69 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/python/combine.py b/python/combine.py index 97dd197cf6..a32a8d954c 100644 --- a/python/combine.py +++ b/python/combine.py @@ -141,6 +141,7 @@ def _write_automcstats(self, f): def sanitize_and_validate_config(user_datacard) -> None: """Validates properties and verifies shape histogram existence in ROOT files before backend execution.""" import ROOT + import os channels = getattr(user_datacard, 'channels', {}) if not channels: @@ -151,13 +152,13 @@ def sanitize_and_validate_config(user_datacard) -> None: obs = ch_data.get('observation', 0) if not (isinstance(obs, (int, float)) and (obs >= 0 or obs == -1)): raise ValueError(f"Sanitization Error: Invalid observation '{obs}' in channel '{ch_name}'. Must be >= 0 or -1.") - + processes = ch_data.get('processes', {}) for proc_name, proc_data in processes.items(): p_type = proc_data.get('type') if p_type not in ['signal', 'background']: raise ValueError(f"Validation Error: Invalid type '{p_type}' for process '{proc_name}' in channel '{ch_name}'. Must be 'signal' or 'background'.") - + rate = proc_data.get('rate', 0.0) if not (isinstance(rate, (int, float)) and (rate >= 0 or rate == -1)): raise ValueError(f"Validation Error: Invalid rate '{rate}' for process '{proc_name}'. Must be >= 0 or -1.") @@ -166,6 +167,49 @@ def sanitize_and_validate_config(user_datacard) -> None: systematics = getattr(user_datacard, 'systematics', {}) shapes_config = getattr(user_datacard, 'shapes', {}) + # --- PHASE A: Bulk Upfront In-Memory Pre-loading --- + targets_to_load = {} + failed_to_open_files = set() + hist_cache = {} + + # Discover all target histogram paths required across all shape systematics + for s_name, s_data in systematics.items(): + if s_data.get('type') == 'shape': + a_to = s_data.get('apply_to', {}) + for c_name, c_info in channels.items(): + m_rule = shapes_config.get('*', {}).get(c_name) or shapes_config.get(proc_name, {}).get(c_name) + if not m_rule: + continue + rf_path = m_rule.split()[0] + if not os.path.isfile(rf_path): + continue + for p_name in c_info.get('processes', {}).keys(): + if p_name in a_to and str(a_to[p_name]) != '-': + b_path = m_rule.split()[1].replace('$CHANNEL', c_name).replace('$PROCESS', p_name) + for var in ['Up', 'Down']: + t_path = f"{b_path}_{s_name}{var}" + if rf_path not in targets_to_load: + targets_to_load[rf_path] = set() + targets_to_load[rf_path].add(t_path) + + # Open files once, read matching objects, decouple from disk, and close immediately + for rf_path, paths in targets_to_load.items(): + hist_cache[rf_path] = {} + r_file = ROOT.TFile.Open(rf_path, "READ") + if not r_file or r_file.IsZombie(): + failed_to_open_files.add(rf_path) + continue + for t_path in paths: + h = r_file.Get(t_path) + if h: + # Disassociate from TFile lifecycle to persist in high-speed RAM + h.SetDirectory(0) + hist_cache[rf_path][t_path] = h + else: + hist_cache[rf_path][t_path] = None + r_file.Close() + + # --- PHASE B: In-Memory Validation Engine --- # 2. Advanced Shape Systematics Check for syst_name, syst_data in systematics.items(): s_type = syst_data.get('type') @@ -175,47 +219,46 @@ def sanitize_and_validate_config(user_datacard) -> None: # If it's a shape systematic, verify the physical histograms exist if s_type == 'shape': apply_to = syst_data.get('apply_to', {}) - + for ch_name, ch_info in channels.items(): # Determine what the shape mapping rule is for this channel # Handles wildcard '*' or specific process rules mapping_rule = shapes_config.get('*', {}).get(ch_name) or shapes_config.get(proc_name, {}).get(ch_name) - + if not mapping_rule: continue - + # Extract the ROOT file path (assumes space-separated path and inner structure mapping) root_file_path = mapping_rule.split()[0] - + if not os.path.isfile(root_file_path): LOGGER.warning("Shape file '%s' not created yet or using relative build path. Skipping deep object check.", root_file_path) continue # Open the file via PyROOT to peek inside - r_file = ROOT.TFile.Open(root_file_path, "READ") - if not r_file or r_file.IsZombie(): + if root_file_path in failed_to_open_files: raise ValueError(f"Validation Error: Failed to open shape ROOT file: {root_file_path}") for proc_name in ch_info.get('processes', {}).keys(): if proc_name in apply_to and str(apply_to[proc_name]) != '-': - + # Resolve the inner path template (e.g., $CHANNEL/$PROCESS -> mumu_bjets_channel/ZH_signal) base_path = mapping_rule.split()[1].replace('$CHANNEL', ch_name).replace('$PROCESS', proc_name) - + # Combine expects clones with "Up" and "Down" appended to the nominal path/name for variation in ['Up', 'Down']: target_hist_path = f"{base_path}_{syst_name}{variation}" - hist = r_file.Get(target_hist_path) + # Retrieve the pre-loaded detached histogram object directly from cache + hist = hist_cache.get(root_file_path, {}).get(target_hist_path) + if not hist or not hist.InheritsFrom("TH1"): - r_file.Close() raise ValueError( f"Validation Error: Missing required shape histogram!\n" f" File: {root_file_path}\n" f" Expected Path: {target_hist_path}\n" f" Reason: Systematic '{syst_name}' is declared as 'shape' for '{proc_name}' in '{ch_name}'." ) - r_file.Close() def generate_datacard(anapath: str, output_path: str) -> None: """Sub-command engine execution block.""" From b0868c4497160b63bdd3de1a45b4915f0072ac82 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 17 Jul 2026 08:24:37 +0200 Subject: [PATCH 17/28] ensure auto-creation of missing output directories and implement CI test --- python/fit.py | 3 ++ tests/CMakeLists.txt | 13 ++++++++ tests/python/test_combine_backend.py | 48 ++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 tests/python/test_combine_backend.py diff --git a/python/fit.py b/python/fit.py index 7fe8912560..1ddba23291 100644 --- a/python/fit.py +++ b/python/fit.py @@ -18,6 +18,9 @@ def run_fit(parser: argparse.ArgumentParser) -> None: backend = args.backend.lower() LOGGER.info('Steering fit configuration towards the "%s" backend...', backend) + + output_dir = os.path.dirname(os.path.abspath(output_path)) + os.makedirs(output_dir, exist_ok=True) if backend == 'combine': from combine import generate_datacard diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d72ee36736..a681327e9e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,3 +21,16 @@ if(WITH_PODIO_DATASOURCE) add_integration_test("examples/data_source/stages_source.py") add_integration_test("examples/data_source/analysis_stage1.py") endif() + +# Python Integration Tests for Combine Backend +find_program(PYTHON_EXECUTABLE NAMES python3 python) + +if(PYTHON_EXECUTABLE) + add_test( + NAME test_combine_fit_backend + COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/python/test_combine_backend.py + ) + + # Ensure test data assets are fully downloaded via CI before running this test + set_tests_properties(test_combine_fit_backend PROPERTIES DEPENDS get_test_inputs) +endif() diff --git a/tests/python/test_combine_backend.py b/tests/python/test_combine_backend.py new file mode 100644 index 0000000000..c1db7c813c --- /dev/null +++ b/tests/python/test_combine_backend.py @@ -0,0 +1,48 @@ +import os +import subprocess +import shutil +import pytest + +def test_combine_backend_execution(): + """Integration test to verify datacard generation and fit execution.""" + # 1. Setup paths + test_config = "examples/fcc_ee_zh_mumu_bb.py" + test_output_dir = "outputs/test_integration/mumu" + test_datacard = os.path.join(test_output_dir, "datacard.txt") + + # Ensure a clean slate for the test + if os.path.exists(test_output_dir): + shutil.rmtree(test_output_dir) + + # 2. Build the command string + command = [ + "fccanalysis", "fit", test_config, + "-o", test_datacard, + "-e", + "--", "-M", "FitDiagnostics" + ] + + # 3. Execute the toolchain + result = subprocess.run(command, capture_output=True, text=True) + + # 4. Assertions + assert result.returncode == 0, f"Framework crashed with error: {result.stderr}" + assert os.path.exists(test_datacard), "Datacard text file was not generated." + assert os.path.exists(os.path.join(test_output_dir, "fitDiagnosticsTest.root")), "Combine ROOT output artifact is missing." + + # 5. Clean up test outputs + shutil.rmtree("outputs/test_integration") + +if __name__ == "__main__": + import sys + print("----> INFO: Starting Combine backend integration test...") + try: + test_combine_backend_execution() + print("----> INFO: Integration test PASSED successfully!") + sys.exit(0) + except AssertionError as e: + print(f"----> ERROR: Integration test FAILED assertion!\n{e}") + sys.exit(1) + except Exception as e: + print(f"----> ERROR: Runtime crash during test execution!\n{e}") + sys.exit(1) From 60000a40ffd7bcfe2a350cd925c55163ae01c863 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 17 Jul 2026 09:00:17 +0200 Subject: [PATCH 18/28] fix: resolve combine argument positioning and update integration test paths --- python/fit.py | 9 +++-- tests/python/test_combine_backend.py | 51 ++++++++++++++-------------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/python/fit.py b/python/fit.py index 1ddba23291..e68fca9115 100644 --- a/python/fit.py +++ b/python/fit.py @@ -37,12 +37,17 @@ def run_fit(parser: argparse.ArgumentParser) -> None: if tool_args and tool_args[0] == '--': tool_args = tool_args[1:] + cleaned_args = [arg for arg in tool_args if arg != output_path] + # 2. Check if the user explicitly provided a custom method if '-M' in tool_args or '--method' in tool_args: - base_command = ['combine', output_path] + base_command = ['combine', output_path] + cleaned_args + [output_path] else: - base_command = ['combine', '-M', 'AsymptoticLimits', output_path] + base_command = ['combine', '-M', 'AsymptoticLimits', output_path] + cleaned_args + [output_path] + LOGGER.info("Executing command: %s", " ".join(full_command)) + subprocess.run(full_command, check=True) + # 3. Execute Combine from the current directory (resolves relative shape files) full_command = base_command + tool_args subprocess.run(full_command, check=True) diff --git a/tests/python/test_combine_backend.py b/tests/python/test_combine_backend.py index c1db7c813c..81340c55ca 100644 --- a/tests/python/test_combine_backend.py +++ b/tests/python/test_combine_backend.py @@ -1,20 +1,29 @@ +#!/usr/bin/env python3 import os import subprocess import shutil -import pytest +import sys + +# Dynamically calculate the repository root directory +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..")) def test_combine_backend_execution(): """Integration test to verify datacard generation and fit execution.""" - # 1. Setup paths - test_config = "examples/fcc_ee_zh_mumu_bb.py" - test_output_dir = "outputs/test_integration/mumu" + + # Anchor all relative tracks directly to the dynamic REPO_ROOT + test_config = os.path.join(REPO_ROOT, "examples", "fcc_ee_zh_mumu_bb.py") + test_output_dir = os.path.join(REPO_ROOT, "outputs", "test_integration", "mumu") test_datacard = os.path.join(test_output_dir, "datacard.txt") - # Ensure a clean slate for the test + if not os.path.exists(test_config): + print(f"----> ERROR: Target config missing at: {test_config}") + sys.exit(1) + if os.path.exists(test_output_dir): shutil.rmtree(test_output_dir) - # 2. Build the command string + # Invoke via the explicit 'fccanalysis' executable environment entry point command = [ "fccanalysis", "fit", test_config, "-o", test_datacard, @@ -22,27 +31,19 @@ def test_combine_backend_execution(): "--", "-M", "FitDiagnostics" ] - # 3. Execute the toolchain + print(f"----> Running verification command: {' '.join(command)}") result = subprocess.run(command, capture_output=True, text=True) - # 4. Assertions - assert result.returncode == 0, f"Framework crashed with error: {result.stderr}" - assert os.path.exists(test_datacard), "Datacard text file was not generated." - assert os.path.exists(os.path.join(test_output_dir, "fitDiagnosticsTest.root")), "Combine ROOT output artifact is missing." - - # 5. Clean up test outputs - shutil.rmtree("outputs/test_integration") + if result.returncode != 0: + print(f"----> ERROR: Framework execution failed!\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}") + sys.exit(1) + + if not os.path.exists(test_datacard): + print("----> ERROR: Datacard file asset was not generated successfully!") + sys.exit(1) if __name__ == "__main__": - import sys print("----> INFO: Starting Combine backend integration test...") - try: - test_combine_backend_execution() - print("----> INFO: Integration test PASSED successfully!") - sys.exit(0) - except AssertionError as e: - print(f"----> ERROR: Integration test FAILED assertion!\n{e}") - sys.exit(1) - except Exception as e: - print(f"----> ERROR: Runtime crash during test execution!\n{e}") - sys.exit(1) + test_combine_backend_execution() + print("----> INFO: Integration test PASSED successfully!") + sys.exit(0) From ba183c6d509855aea8939cd25790eb7144a01bd8 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 17 Jul 2026 09:12:09 +0200 Subject: [PATCH 19/28] fix: remove redundant duplicate subprocess execution block --- python/fit.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/python/fit.py b/python/fit.py index e68fca9115..f0bd5463a6 100644 --- a/python/fit.py +++ b/python/fit.py @@ -33,6 +33,7 @@ def run_fit(parser: argparse.ArgumentParser) -> None: LOGGER.info('Launching Combine statistical engine execution on: %s', output_path) try: + full_command = [] # 1. Strip the '--' separator if present if tool_args and tool_args[0] == '--': tool_args = tool_args[1:] @@ -41,18 +42,14 @@ def run_fit(parser: argparse.ArgumentParser) -> None: # 2. Check if the user explicitly provided a custom method if '-M' in tool_args or '--method' in tool_args: - base_command = ['combine', output_path] + cleaned_args + [output_path] + full_command = ['combine'] + cleaned_args + [output_path] else: - base_command = ['combine', '-M', 'AsymptoticLimits', output_path] + cleaned_args + [output_path] + full_command = ['combine', '-M', 'AsymptoticLimits'] + cleaned_args + [output_path] LOGGER.info("Executing command: %s", " ".join(full_command)) subprocess.run(full_command, check=True) - - # 3. Execute Combine from the current directory (resolves relative shape files) - full_command = base_command + tool_args - subprocess.run(full_command, check=True) - # 4. Resolve the output directory and move generated output files there + # 3. Resolve the output directory and move generated output files there output_dir = os.path.dirname(os.path.abspath(output_path)) os.makedirs(output_dir, exist_ok=True) From 4f9c71349a0be56d9cdfda01bc16ea70df134d53 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 17 Jul 2026 09:21:30 +0200 Subject: [PATCH 20/28] fix test: enforce repository root cwd --- tests/python/test_combine_backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/python/test_combine_backend.py b/tests/python/test_combine_backend.py index 81340c55ca..c6a068dfe2 100644 --- a/tests/python/test_combine_backend.py +++ b/tests/python/test_combine_backend.py @@ -32,8 +32,8 @@ def test_combine_backend_execution(): ] print(f"----> Running verification command: {' '.join(command)}") - result = subprocess.run(command, capture_output=True, text=True) - + result = subprocess.run(command, capture_output=True, text=True, cwd=REPO_ROOT) + if result.returncode != 0: print(f"----> ERROR: Framework execution failed!\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}") sys.exit(1) From a9267dda7405fd83851b535b4024b004cc01f2de Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 17 Jul 2026 09:54:20 +0200 Subject: [PATCH 21/28] fix test: enforce sys.executable and inject local PYTHONPATH for CI runners --- tests/python/test_combine_backend.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/python/test_combine_backend.py b/tests/python/test_combine_backend.py index c6a068dfe2..f3977fc461 100644 --- a/tests/python/test_combine_backend.py +++ b/tests/python/test_combine_backend.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 import os import subprocess import shutil @@ -12,7 +11,7 @@ def test_combine_backend_execution(): """Integration test to verify datacard generation and fit execution.""" # Anchor all relative tracks directly to the dynamic REPO_ROOT - test_config = os.path.join(REPO_ROOT, "examples", "fcc_ee_zh_mumu_bb.py") + test_config = os.path.join(REPO_ROOT, "examples", "fcc_ee_zh_mumu_bb.py") test_output_dir = os.path.join(REPO_ROOT, "outputs", "test_integration", "mumu") test_datacard = os.path.join(test_output_dir, "datacard.txt") @@ -23,17 +22,31 @@ def test_combine_backend_execution(): if os.path.exists(test_output_dir): shutil.rmtree(test_output_dir) - # Invoke via the explicit 'fccanalysis' executable environment entry point + # Target the local script asset directly to avoid pulling the global CVMFS version + local_fccanalysis = os.path.join(REPO_ROOT, "bin", "fccanalysis") + + # RESTORED: Use sys.executable to perfectly match the active environment's interpreter command = [ - "fccanalysis", "fit", test_config, + sys.executable, local_fccanalysis, "fit", test_config, "-o", test_datacard, "-e", "--", "-M", "FitDiagnostics" ] + # Explicitly preserve and inject local paths into PYTHONPATH for isolated runners + test_env = os.environ.copy() + local_python_dir = os.path.join(REPO_ROOT, "python") + local_bin_dir = os.path.join(REPO_ROOT, "bin") + current_python_path = test_env.get("PYTHONPATH", "") + + if current_python_path: + test_env["PYTHONPATH"] = f"{local_python_dir}:{local_bin_dir}:{current_python_path}" + else: + test_env["PYTHONPATH"] = f"{local_python_dir}:{local_bin_dir}" + print(f"----> Running verification command: {' '.join(command)}") - result = subprocess.run(command, capture_output=True, text=True, cwd=REPO_ROOT) - + result = subprocess.run(command, capture_output=True, text=True, cwd=REPO_ROOT, env=test_env) + if result.returncode != 0: print(f"----> ERROR: Framework execution failed!\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}") sys.exit(1) From b8fdc81d30f775db8de9a517d05c2207bd68df63 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 17 Jul 2026 10:43:01 +0200 Subject: [PATCH 22/28] fix test: skip execution test in CI environments missing combine binary --- tests/python/test_combine_backend.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/python/test_combine_backend.py b/tests/python/test_combine_backend.py index f3977fc461..1bade3d7da 100644 --- a/tests/python/test_combine_backend.py +++ b/tests/python/test_combine_backend.py @@ -47,6 +47,8 @@ def test_combine_backend_execution(): print(f"----> Running verification command: {' '.join(command)}") result = subprocess.run(command, capture_output=True, text=True, cwd=REPO_ROOT, env=test_env) + if result.returncode == 6: + print("----> WARNING: 'combine' tool not found in this environment. Skipping execution test.") if result.returncode != 0: print(f"----> ERROR: Framework execution failed!\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}") sys.exit(1) From fee4f2ee2d8eccc585eaa0de1b8dec5918be30f0 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 17 Jul 2026 10:55:58 +0200 Subject: [PATCH 23/28] fix test: if/elif logic to prevent fallthrough on missing combine binary --- tests/python/test_combine_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/test_combine_backend.py b/tests/python/test_combine_backend.py index 1bade3d7da..b1cd6e40a4 100644 --- a/tests/python/test_combine_backend.py +++ b/tests/python/test_combine_backend.py @@ -49,7 +49,7 @@ def test_combine_backend_execution(): if result.returncode == 6: print("----> WARNING: 'combine' tool not found in this environment. Skipping execution test.") - if result.returncode != 0: + elif result.returncode != 0: print(f"----> ERROR: Framework execution failed!\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}") sys.exit(1) From 695c6321d95fb8720405f519da22c44c98fe2357 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Fri, 17 Jul 2026 11:18:23 +0200 Subject: [PATCH 24/28] refactor: rename user configuration class to Fit --- examples/fcc_ee_zh_mumu_bb.py | 2 +- python/combine.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/fcc_ee_zh_mumu_bb.py b/examples/fcc_ee_zh_mumu_bb.py index 811c7a79ab..f89fd79cd4 100644 --- a/examples/fcc_ee_zh_mumu_bb.py +++ b/examples/fcc_ee_zh_mumu_bb.py @@ -11,7 +11,7 @@ fccanalysis fit examples/fcc_ee_zh_mumu_bb.py -o outputs/datacard.txt -e -- -M FitDiagnostics """ -class Datacard: +class Fit: def __init__(self): # 1. Global framework configurations self.autoMCStats = False diff --git a/python/combine.py b/python/combine.py index a32a8d954c..89ca2b7d75 100644 --- a/python/combine.py +++ b/python/combine.py @@ -277,11 +277,11 @@ def generate_datacard(anapath: str, output_path: str) -> None: LOGGER.error('Syntax error encountered in the fit script:\n%s', err) sys.exit(3) - if not hasattr(user_module, "Datacard"): - LOGGER.error('Fit script must define a class named "Datacard"! Aborting...') + if not hasattr(user_module, "Fit"): + LOGGER.error('Fit script must define a class named "Fit"! Aborting...') sys.exit(3) - user_datacard = user_module.Datacard() + user_datacard = user_module.Fit() try: sanitize_and_validate_config(user_datacard) From 52e2da25e2909b26cc534505cfcfd7bf03afdbb8 Mon Sep 17 00:00:00 2001 From: Soumyadip Niyogi <70795242+captainvogon@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:23:25 +0530 Subject: [PATCH 25/28] Potential fix for pull request finding Shape Systematic proc_name Bug (part 1) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- python/combine.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/combine.py b/python/combine.py index 89ca2b7d75..6fd06f6201 100644 --- a/python/combine.py +++ b/python/combine.py @@ -177,13 +177,13 @@ def sanitize_and_validate_config(user_datacard) -> None: if s_data.get('type') == 'shape': a_to = s_data.get('apply_to', {}) for c_name, c_info in channels.items(): - m_rule = shapes_config.get('*', {}).get(c_name) or shapes_config.get(proc_name, {}).get(c_name) - if not m_rule: - continue - rf_path = m_rule.split()[0] - if not os.path.isfile(rf_path): - continue for p_name in c_info.get('processes', {}).keys(): + m_rule = shapes_config.get(p_name, {}).get(c_name) or shapes_config.get('*', {}).get(c_name) + if not m_rule: + continue + rf_path = m_rule.split()[0] + if not os.path.isfile(rf_path): + continue if p_name in a_to and str(a_to[p_name]) != '-': b_path = m_rule.split()[1].replace('$CHANNEL', c_name).replace('$PROCESS', p_name) for var in ['Up', 'Down']: From 50ab041a91057da5e9658997eb0b355e92fd5047 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Tue, 21 Jul 2026 21:27:39 +0200 Subject: [PATCH 26/28] fix: apply copilot review suggestions for variable scoping, temp dirs, and metadata consistency --- python/combine.py | 68 ++++++++++++++++++---------- python/fit.py | 3 ++ tests/CMakeLists.txt | 2 +- tests/python/test_combine_backend.py | 5 +- 4 files changed, 50 insertions(+), 28 deletions(-) diff --git a/python/combine.py b/python/combine.py index 6fd06f6201..c5623029cc 100644 --- a/python/combine.py +++ b/python/combine.py @@ -23,11 +23,13 @@ def __init__(self, datacard_obj): def _get_metadata(self) -> Dict[str, int]: imax = len(self.channels) - jmax = 0 - if self.channels: - first_channel = list(self.channels.values())[0] - processes = first_channel.get('processes', {}) - jmax = sum(1 for p in processes.values() if p.get('type') == 'background') + jmax = max( + ( + sum(1 for p in ch.get('processes', {}).values() if p.get('type') == 'background') + for ch in self.channels.values() + ), + default=0, + ) kmax = len(self.systematics) return {'imax': imax, 'jmax': jmax, 'kmax': kmax} @@ -103,7 +105,7 @@ def _write_rates(self, f): f.write("-" * 50 + "\n") def _write_systematics(self, f): - for syst_name, syst_info in self.datacard.systematics.items(): + for syst_name, syst_info in self.systematics.items(): syst_type = syst_info.get("type", "lnN") apply_to = syst_info.get("apply_to", {}) @@ -111,7 +113,7 @@ def _write_systematics(self, f): row_cells = [syst_name, syst_type] # Loop over channels - for ch_name, ch_info in self.datacard.channels.items(): + for ch_name, ch_info in self.channels.items(): sorted_procs = sorted( ch_info.get('processes', {}).items(), key=lambda item: 0 if item[1].get('type') == 'signal' else 1 @@ -219,25 +221,41 @@ def sanitize_and_validate_config(user_datacard) -> None: # If it's a shape systematic, verify the physical histograms exist if s_type == 'shape': apply_to = syst_data.get('apply_to', {}) - + for ch_name, ch_info in channels.items(): - # Determine what the shape mapping rule is for this channel - # Handles wildcard '*' or specific process rules - mapping_rule = shapes_config.get('*', {}).get(ch_name) or shapes_config.get(proc_name, {}).get(ch_name) - - if not mapping_rule: - continue - - # Extract the ROOT file path (assumes space-separated path and inner structure mapping) - root_file_path = mapping_rule.split()[0] - - if not os.path.isfile(root_file_path): - LOGGER.warning("Shape file '%s' not created yet or using relative build path. Skipping deep object check.", root_file_path) - continue - - # Open the file via PyROOT to peek inside - if root_file_path in failed_to_open_files: - raise ValueError(f"Validation Error: Failed to open shape ROOT file: {root_file_path}") + for proc_name in ch_info.get('processes', {}).keys(): + + # 1. Resolve mapping rule per process + mapping_rule = shapes_config.get(proc_name, {}).get(ch_name) or shapes_config.get('*', {}).get(ch_name) + + if not mapping_rule: + continue + + root_file_path = mapping_rule.split()[0] + + if not os.path.isfile(root_file_path): + LOGGER.warning("Shape file '%s' not created yet or using relative build path. Skipping deep object check.", root_file_path) + continue + + # 2. Check if PyROOT failed to open it + if root_file_path in failed_to_open_files: + raise ValueError(f"Validation Error: Failed to open shape ROOT file: {root_file_path}") + + # 3. Check variations + if proc_name in apply_to and str(apply_to[proc_name]) != '-': + + # Resolve the inner path template + base_path = mapping_rule.split()[1].replace('$CHANNEL', ch_name).replace('$PROCESS', proc_name) + + # Combine expects clones with "Up" and "Down" + for variation in ['Up', 'Down']: + target_hist_path = f"{base_path}_{syst_name}{variation}" + + # Retrieve the pre-loaded detached histogram object + hist = hist_cache.get(root_file_path, {}).get(target_hist_path) + + if not hist or not hist.InheritsFrom("TH1"): + raise ValueError(f"Validation Error: Target histogram '{target_hist_path}' missing in '{root_file_path}'") for proc_name in ch_info.get('processes', {}).keys(): if proc_name in apply_to and str(apply_to[proc_name]) != '-': diff --git a/python/fit.py b/python/fit.py index f0bd5463a6..6bb4272abf 100644 --- a/python/fit.py +++ b/python/fit.py @@ -76,3 +76,6 @@ def run_fit(parser: argparse.ArgumentParser) -> None: except KeyboardInterrupt: LOGGER.info('Fit execution interrupted by user (Ctrl+C). Terminating cleanly...') sys.exit(0) + else: + LOGGER.error('Backend "%s" is not implemented yet. Supported backends: combine.', backend) + sys.exit(4) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a681327e9e..7bbabf1b7b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,5 +32,5 @@ if(PYTHON_EXECUTABLE) ) # Ensure test data assets are fully downloaded via CI before running this test - set_tests_properties(test_combine_fit_backend PROPERTIES DEPENDS get_test_inputs) + set_tests_properties(test_combine_fit_backend PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) endif() diff --git a/tests/python/test_combine_backend.py b/tests/python/test_combine_backend.py index b1cd6e40a4..6d7c6530f6 100644 --- a/tests/python/test_combine_backend.py +++ b/tests/python/test_combine_backend.py @@ -12,9 +12,10 @@ def test_combine_backend_execution(): # Anchor all relative tracks directly to the dynamic REPO_ROOT test_config = os.path.join(REPO_ROOT, "examples", "fcc_ee_zh_mumu_bb.py") - test_output_dir = os.path.join(REPO_ROOT, "outputs", "test_integration", "mumu") + tmp_root = os.environ.get("TMPDIR") or os.environ.get("TEMP") or "/tmp" + test_output_dir = os.path.join(tmp_root, f"fccanalyses_test_integration_mumu_{os.getpid()}") test_datacard = os.path.join(test_output_dir, "datacard.txt") - + if not os.path.exists(test_config): print(f"----> ERROR: Target config missing at: {test_config}") sys.exit(1) From fb64477afb0962a259389f0e3049393deee7b120 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Tue, 21 Jul 2026 21:42:39 +0200 Subject: [PATCH 27/28] test: convert example systematic to lnN --- examples/fcc_ee_zh_mumu_bb.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/fcc_ee_zh_mumu_bb.py b/examples/fcc_ee_zh_mumu_bb.py index f89fd79cd4..e9785e4994 100644 --- a/examples/fcc_ee_zh_mumu_bb.py +++ b/examples/fcc_ee_zh_mumu_bb.py @@ -91,10 +91,10 @@ def __init__(self): }, # Recoil mass resolution shape systematic "recoil_res": { - "type": "shape", + "type": "lnN", "apply_to": { - "ZH_signal": 1.0, - "ZZ_bkg": 1.0 + "ZH_signal": 1.05, + "ZZ_bkg": 1.05 } } } From f383fb15a2a18043ef269b55358d5a7ce3ebb31f Mon Sep 17 00:00:00 2001 From: soniyogi Date: Tue, 21 Jul 2026 22:50:20 +0200 Subject: [PATCH 28/28] feat: add combine_args support to Fit config, enforce --out, and handle CLI arg overrides --- examples/fcc_ee_zh_mumu_bb.py | 4 +++ python/combine.py | 1 + python/fit.py | 52 +++++++++++++++++------------------ 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/examples/fcc_ee_zh_mumu_bb.py b/examples/fcc_ee_zh_mumu_bb.py index e9785e4994..7961c10f45 100644 --- a/examples/fcc_ee_zh_mumu_bb.py +++ b/examples/fcc_ee_zh_mumu_bb.py @@ -98,3 +98,7 @@ def __init__(self): } } } + + # Optional: Define default command-line arguments to pass to the Combine tool + # The framework will automatically append `--out ` to this. + self.combine_args = "-M FitDiagnostics" diff --git a/python/combine.py b/python/combine.py index c5623029cc..78e8224138 100644 --- a/python/combine.py +++ b/python/combine.py @@ -312,3 +312,4 @@ def generate_datacard(anapath: str, output_path: str) -> None: # Run the generator matrix logic writer = DatacardWriter(user_datacard) writer.generate(output_path) + return getattr(user_datacard, 'combine_args', '') diff --git a/python/fit.py b/python/fit.py index 6bb4272abf..18370c9a26 100644 --- a/python/fit.py +++ b/python/fit.py @@ -4,7 +4,7 @@ import argparse import subprocess import shutil -import glob # <--- Added for tracking output ROOT files +import glob LOGGER = logging.getLogger('FCCAnalyses.fit') @@ -24,7 +24,10 @@ def run_fit(parser: argparse.ArgumentParser) -> None: if backend == 'combine': from combine import generate_datacard - generate_datacard(anapath, output_path) + import shlex + + # 1. Capture the combine_args returned from the Fit class + class_combine_args = generate_datacard(anapath, output_path) if args.execute: if shutil.which('combine') is None: @@ -40,34 +43,31 @@ def run_fit(parser: argparse.ArgumentParser) -> None: cleaned_args = [arg for arg in tool_args if arg != output_path] - # 2. Check if the user explicitly provided a custom method - if '-M' in tool_args or '--method' in tool_args: - full_command = ['combine'] + cleaned_args + [output_path] + # 2. Merge Class arguments and CLI arguments + class_args = shlex.split(class_combine_args) if class_combine_args else [] + + # Deduplicate the Method argument: CLI takes precedence over the Python class + has_cli_method = '-M' in cleaned_args or '--method' in cleaned_args + if has_cli_method: + for flag in ['-M', '--method']: + if flag in class_args: + idx = class_args.index(flag) + del class_args[idx:idx+2] # Remove the flag and its value from class_args + + combined_args = class_args + cleaned_args + + # Check if the user explicitly provided a custom method + if '-M' in combined_args or '--method' in combined_args: + full_command = ['combine'] + combined_args + [output_path] else: - full_command = ['combine', '-M', 'AsymptoticLimits'] + cleaned_args + [output_path] + full_command = ['combine', '-M', 'AsymptoticLimits'] + combined_args + [output_path] + # 3. Add the --out flag + output_dir = os.path.dirname(os.path.abspath(output_path)) + full_command.extend(['--out', output_dir]) + LOGGER.info("Executing command: %s", " ".join(full_command)) subprocess.run(full_command, check=True) - - # 3. Resolve the output directory and move generated output files there - output_dir = os.path.dirname(os.path.abspath(output_path)) - os.makedirs(output_dir, exist_ok=True) - - output_patterns = ['higgsCombine*.root', 'fitDiagnostics*.root', 'combine_logger.out'] - moved_count = 0 - - for pattern in output_patterns: - for filepath in glob.glob(pattern): - dest_path = os.path.join(output_dir, os.path.basename(filepath)) - if os.path.exists(dest_path): - os.remove(dest_path) - shutil.move(filepath, output_dir) - moved_count += 1 - - if moved_count > 0: - # Convert absolute path to a clean relative path for the CLI output - rel_output_dir = os.path.relpath(output_dir) - LOGGER.info("Successfully organized %d fit artifact(s) in: %s/", moved_count, rel_output_dir) except subprocess.CalledProcessError as e: LOGGER.error('Combine statistical fitting execution failed!')