From 669946c83398b81187c5f3f1234fd3146d64b820 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Thu, 25 Jun 2026 16:38:48 +0200 Subject: [PATCH 01/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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!') From 83ef04e4f3b0b3fc95cf74f8a39d4e0a44cf6283 Mon Sep 17 00:00:00 2001 From: Soumyadip Niyogi <70795242+captainvogon@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:50:20 +0000 Subject: [PATCH 29/36] update default Combine exec method from AsymptoticLimits to MultiDimFit --- python/fit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fit.py b/python/fit.py index 18370c9a26..573d64f038 100644 --- a/python/fit.py +++ b/python/fit.py @@ -60,7 +60,7 @@ def run_fit(parser: argparse.ArgumentParser) -> None: if '-M' in combined_args or '--method' in combined_args: full_command = ['combine'] + combined_args + [output_path] else: - full_command = ['combine', '-M', 'AsymptoticLimits'] + combined_args + [output_path] + full_command = ['combine', '-M', 'MultiDimFit'] + combined_args + [output_path] # 3. Add the --out flag output_dir = os.path.dirname(os.path.abspath(output_path)) From 0642801d48dc222efc7a7d326b7a72b4fbb4133e Mon Sep 17 00:00:00 2001 From: soniyogi Date: Sat, 8 Aug 2026 09:30:16 +0200 Subject: [PATCH 30/36] feat: enable autodiff by default and handle --out overrides --- python/fit.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/python/fit.py b/python/fit.py index 573d64f038..198ddd06a3 100644 --- a/python/fit.py +++ b/python/fit.py @@ -56,15 +56,20 @@ def run_fit(parser: argparse.ArgumentParser) -> None: combined_args = class_args + cleaned_args + # Add auto-differentiation by default if not already specified in class or CLI args + if not any('MINIMIZER_analytic' in arg for arg in combined_args): + combined_args = ['--X-rtd', 'MINIMIZER_analytic'] + combined_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', 'MultiDimFit'] + 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]) + # 3. Add default --out flag only if not explicitly provided by the user + if '--out' not in combined_args: + 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) From 27a5faa653d10606bb9ad50e379ba897857d9d48 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Sat, 15 Aug 2026 12:24:30 +0200 Subject: [PATCH 31/36] feat: integrate python-based ROOT extraction and add extract subcommand --- bin/fccanalysis | 3 ++ python/extract.py | 129 ++++++++++++++++++++++++++++++++++++++++++++++ python/fit.py | 35 +++++++------ python/parsers.py | 9 ++++ 4 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 python/extract.py diff --git a/bin/fccanalysis b/bin/fccanalysis index 82c90d7e9a..cebdc234ff 100755 --- a/bin/fccanalysis +++ b/bin/fccanalysis @@ -17,6 +17,7 @@ from run_final_analysis import run_final from do_plots import do_plots from do_combine import do_combine from fit import run_fit +from extract import run_extract # _____________________________________________________________________________ @@ -128,6 +129,8 @@ def main(): do_combine(parser) elif args.command == 'fit': run_fit(parser) + elif args.command == 'extract': + run_extract(parser) else: run(parser) diff --git a/python/extract.py b/python/extract.py new file mode 100644 index 0000000000..0731a732ac --- /dev/null +++ b/python/extract.py @@ -0,0 +1,129 @@ +import os +import glob +import logging +import argparse + +LOGGER = logging.getLogger('FCCAnalyses.extract') + +def find_crossing(x_vals, y_vals, target=1.0, left=True, best_fit=0.0): + """Finds the linearly interpolated x-value where y crosses the target threshold.""" + pairs = sorted(zip(x_vals, y_vals)) + if left: + filtered = [p for p in pairs if p[0] <= best_fit] + filtered.reverse() + else: + filtered = [p for p in pairs if p[0] >= best_fit] + + for i in range(len(filtered) - 1): + x1, y1 = filtered[i] + x2, y2 = filtered[i+1] + + if (y1 <= target <= y2) or (y2 <= target <= y1): + if y2 == y1: return x1 + return x1 + (target - y1) * (x2 - x1) / (y2 - y1) + return None + +def extract_fit_results(target_path): + """Extracts uncertainties from a specific Combine ROOT file or directory.""" + try: + import ROOT + ROOT.gROOT.SetBatch(True) + ROOT.PyConfig.IgnoreCommandLineOptions = True + except ImportError: + LOGGER.error("PyROOT is not available. Cannot extract uncertainties.") + return + + if os.path.isdir(target_path): + root_files = glob.glob(os.path.join(target_path, "higgsCombine*.root")) + if not root_files: + LOGGER.warning("No higgsCombine output ROOT file found in directory: %s", target_path) + return + target_file = max(root_files, key=os.path.getmtime) + else: + target_file = target_path + + if not os.path.exists(target_file): + LOGGER.error("Target file does not exist: %s", target_file) + return + + LOGGER.info("Extracting fit results from: %s", target_file) + f_in = ROOT.TFile.Open(target_file, "READ") + if not f_in or f_in.IsZombie(): + LOGGER.error("Could not open output ROOT file: %s", target_file) + return + + tree = f_in.Get("limit") + if not tree: + LOGGER.error("Could not find 'limit' TTree inside %s", target_file) + f_in.Close() + return + + tree.GetEntry(0) + + if hasattr(tree, "deltaNLL"): + x_vals, y_vals = [], [] + + ignore_branches = {"limit", "limitErr", "syst", "iTheta", "iEta", "quantileExpected", "deltaNLL", "t_cpu", "t_real"} + branch_names = [b.GetName() for b in tree.GetListOfBranches()] + candidates = [b for b in branch_names if b not in ignore_branches] + + if "r" in candidates: + poi_name = "r" + elif candidates: + poi_name = next((c for c in candidates if c != "mh"), candidates[0]) + else: + poi_name = "r" + + for i in range(tree.GetEntries()): + tree.GetEntry(i) + # Relaxed the deltaNLL filter to 1000 so steep curves aren't deleted + if tree.quantileExpected < -1.5 or tree.deltaNLL > 1000: continue + + val = getattr(tree, poi_name, None) + if val is not None: + x_vals.append(val) + y_vals.append(2.0 * tree.deltaNLL) + + if len(x_vals) > 1: + sorted_pairs = sorted(zip(x_vals, y_vals)) + x_vals, y_vals = zip(*sorted_pairs) + min_idx = y_vals.index(min(y_vals)) + best_fit = x_vals[min_idx] + + unc_left = find_crossing(x_vals, y_vals, target=1.0, left=True, best_fit=best_fit) + unc_right = find_crossing(x_vals, y_vals, target=1.0, left=False, best_fit=best_fit) + + err_down = (unc_left - best_fit) if unc_left is not None else float('nan') + err_up = (unc_right - best_fit) if unc_right is not None else float('nan') + + print("\n" + "="*50) + print(f" FIT RESULTS: MultiDimFit ({poi_name})") + print("="*50) + print(f" Best-fit {poi_name} : {best_fit:.6f}") + print(f" -1 Sigma (68% CL): {err_down:+.6f} (Value: {unc_left:.6f})" if unc_left else " -1 Sigma (68% CL): N/A") + print(f" +1 Sigma (68% CL): {err_up:+.6f} (Value: {unc_right:.6f})" if unc_right else " +1 Sigma (68% CL): N/A") + print("="*50 + "\n") + else: + LOGGER.warning("Tree contains only 1 point. Run Combine with a scan (e.g., --algo grid) to extract crossing uncertainties.") + + else: + limits = {} + for i in range(tree.GetEntries()): + tree.GetEntry(i) + limits[round(tree.quantileExpected, 3)] = tree.limit + + print("\n" + "="*50) + print(" FIT RESULTS: AsymptoticLimits (95% CL)") + print("="*50) + if -1.0 in limits: print(f" Observed Limit : r < {limits[-1.0]:.6f}") + if 0.5 in limits: print(f" Expected Median : r < {limits[0.5]:.6f}") + if 0.16 in limits and 0.84 in limits: print(f" Expected 68% Band: {limits[0.16]:.6f} - {limits[0.84]:.6f}") + if 0.025 in limits and 0.975 in limits: print(f" Expected 95% Band: {limits[0.025]:.6f} - {limits[0.975]:.6f}") + print("="*50 + "\n") + + f_in.Close() + +def run_extract(parser: argparse.ArgumentParser) -> None: + """Sub-command entry point for extracting uncertainties.""" + args, _ = parser.parse_known_args() + extract_fit_results(args.input_path) diff --git a/python/fit.py b/python/fit.py index 198ddd06a3..b6b2351767 100644 --- a/python/fit.py +++ b/python/fit.py @@ -5,6 +5,7 @@ import subprocess import shutil import glob +from extract import extract_fit_results LOGGER = logging.getLogger('FCCAnalyses.fit') @@ -26,7 +27,6 @@ def run_fit(parser: argparse.ArgumentParser) -> None: from combine import generate_datacard import shlex - # 1. Capture the combine_args returned from the Fit class class_combine_args = generate_datacard(anapath, output_path) if args.execute: @@ -37,42 +37,45 @@ 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:] cleaned_args = [arg for arg in tool_args if arg != 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 + del class_args[idx:idx+2] combined_args = class_args + cleaned_args - # Add auto-differentiation by default if not already specified in class or CLI args if not any('MINIMIZER_analytic' in arg for arg in combined_args): combined_args = ['--X-rtd', 'MINIMIZER_analytic'] + combined_args - # Check if the user explicitly provided a custom method + # Build the command WITHOUT forcing --out if '-M' in combined_args or '--method' in combined_args: full_command = ['combine'] + combined_args + [output_path] else: full_command = ['combine', '-M', 'MultiDimFit'] + combined_args + [output_path] - - # 3. Add default --out flag only if not explicitly provided by the user - if '--out' not in combined_args: - 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) + + # --- ROBUST ARTIFACT SHIFTING --- + # Scoop up any ROOT files Combine dropped in the main directory + artifacts = glob.glob("higgsCombine*.root") + glob.glob("fitDiagnostics*.root") + for artifact in artifacts: + dest_file = os.path.join(output_dir, os.path.basename(artifact)) + # Remove old versions if they exist in the target directory + if os.path.exists(dest_file): + os.remove(dest_file) + shutil.move(artifact, output_dir) + + # Automatically extract and display results from the correct directory + extract_fit_results(output_dir) except subprocess.CalledProcessError as e: LOGGER.error('Combine statistical fitting execution failed!') @@ -81,6 +84,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) + else: + LOGGER.error('Backend "%s" is not implemented yet. Supported backends: combine.', backend) + sys.exit(4) diff --git a/python/parsers.py b/python/parsers.py index af5886dc98..f97198e978 100644 --- a/python/parsers.py +++ b/python/parsers.py @@ -349,6 +349,15 @@ def setup_subparsers(topparser): 'fit', help="generate combine datacards using object-oriented Python config." "Use '-- [args]' to forward custom flags directly to the backend engine.") + parser_extract = topparser.add_parser( + 'extract', + help='Extract fit parameters and uncertainties directly from Combine ROOT files' + ) + parser_extract.add_argument( + 'input_path', + type=str, + help='Path to a higgsCombine ROOT file, or a directory containing one.' + ) # Register sub-parsers setup_build_parser(parser_build) From 91411227aa314281f9eb7f2417620fb7751caf80 Mon Sep 17 00:00:00 2001 From: soniyogi Date: Sat, 15 Aug 2026 12:59:30 +0200 Subject: [PATCH 32/36] docs: add man pages and documentation for fit and extract subcommands --- man/man1/fccanalysis-extract.1 | 33 +++++++++++++++++++++ man/man1/fccanalysis-fit.1 | 54 ++++++++++++++++++++++++++++++++++ man/man1/fccanalysis-run.1 | 8 ++++- man/man7/fccanalysis-script.7 | 23 ++++++++++++++- 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 man/man1/fccanalysis-extract.1 create mode 100644 man/man1/fccanalysis-fit.1 diff --git a/man/man1/fccanalysis-extract.1 b/man/man1/fccanalysis-extract.1 new file mode 100644 index 0000000000..eb21ae2758 --- /dev/null +++ b/man/man1/fccanalysis-extract.1 @@ -0,0 +1,33 @@ +.\" Manpage for fccanalysis-extract +.\" Contact FCC-PED-SoftwareAndComputing-Analysis@cern.ch to correct errors or typos. +.TH FCCANALYSIS\-EXTRACT 1 "August 2026" "0.12.0" "fccanalysis-extract man page" + +.SH NAME +\fBfccanalysis\-extract\fR \- extract parameters and uncertainties from Combine ROOT files + +.SH SYNOPSIS +.B fccanalysis extract +[\fB\-h\fR | \fB\-\-help\fR] +.B input_path + +.SH DESCRIPTION +.B fccanalysis\-extract +is a standalone utility that reads ROOT files generated by Higgs Combine (e.g., +\fIhiggsCombineTest.MultiDimFit.mH120.root\fR). It performs dynamic branch detection +to locate the Parameter of Interest (POI) and applies linear interpolation to +calculate and display the 1-sigma (68% CL) crossing uncertainties. + +It safely handles grid scans and gracefully falls back to displaying expected and +observed quantiles if the file was generated using the AsymptoticLimits method. + +.SH OPTIONS +.TP +.B input_path +Path to a specific \fIhiggsCombine*.root\fR file, or a directory containing one. +If a directory is provided, the most recently modified Combine ROOT file will be used. +.TP +.BR \-h ", " \-\-help +Prints short help message and exits\&. + +.SH SEE ALSO +fccanalysis(1), fccanalysis-fit(1) diff --git a/man/man1/fccanalysis-fit.1 b/man/man1/fccanalysis-fit.1 new file mode 100644 index 0000000000..1068057ad1 --- /dev/null +++ b/man/man1/fccanalysis-fit.1 @@ -0,0 +1,54 @@ +.\" Manpage for fccanalysis-fit +.\" Contact FCC-PED-SoftwareAndComputing-Analysis@cern.ch to correct errors or typos. +.TH FCCANALYSIS\-FIT 1 "August 2026" "0.12.0" "fccanalysis-fit man page" + +.SH NAME +\fBfccanalysis\-fit\fR \- generate combine datacards and execute statistical fits + +.SH SYNOPSIS +.B fccanalysis fit +[\fB\-h\fR | \fB\-\-help\fR] +[\fB\-o\fR \fIOUTPUT\fR | \fB\-\-output\fR \fIOUTPUT\fR] +[\fB\-b\fR \fIBACKEND\fR | \fB\-\-backend\fR \fIBACKEND\fR] +[\fB\-e\fR | \fB\-\-execute\fR] +.B analysis-script +\-\- +[\fICOMBINE ARGUMENTS\fR] + +.SH DESCRIPTION +.B fccanalysis\-fit +parses an object-oriented Python configuration script containing a \fIFit\fR class +to dynamically generate a text-based datacard for statistical inference. + +If the \fB\-\-execute\fR flag is provided, the script will automatically invoke the +underlying statistical engine (e.g., Higgs Combine) on the generated datacard. +Output ROOT files are automatically shifted to the datacard directory, and +1-sigma crossing uncertainties are automatically extracted and displayed. + +.SH OPTIONS +.TP +.B analysis-script +Path to the Python analysis script containing the \fIFit\fR class. +.TP +.BR \-h ", " \-\-help +Prints short help message and exits\&. +.TP +\fB\-o\fR \fIOUTPUT\fR, \fB\-\-output\fR \fIOUTPUT\fR +Path where the generated datacard.txt will be saved\&. +.TP +\fB\-b\fR \fIBACKEND\fR, \fB\-\-backend\fR \fIBACKEND\fR +Specify the statistical backend to use\&. Currently supported: \fBcombine\fR. +.br +Default value: \fBcombine\fR. +.TP +.BR \-e ", " \-\-execute +Execute the statistical backend after generating the datacard. +.TP +.I COMBINE ARGUMENTS +Command line arguments passed after the \fB\-\-\fR separator will not be parsed +by the framework and will be forwarded directly to the backend Combine engine +(e.g., \fB\-M FitDiagnostics\fR or \fB\-\-algo grid\fR). These override any default +arguments defined in the Python class. + +.SH SEE ALSO +fccanalysis(1), fccanalysis-script(7), fccanalysis-extract(1) diff --git a/man/man1/fccanalysis-run.1 b/man/man1/fccanalysis-run.1 index 7b3e0eb80f..524d2f2eb9 100644 --- a/man/man1/fccanalysis-run.1 +++ b/man/man1/fccanalysis-run.1 @@ -20,6 +20,7 @@ [\fB\-\-graph\-path\fR \fIGRAPH_PATH\fR] [\fB\-\-use-data-source\fR] [\fB\-\-output\-dir\fR \fIOUTPUT_DIR\fR] +[\fB\-\-output\-format\fR {\fBttree\fR,\fBrntuple\fR}] [\fB\-a\fR \fIANALYSIS_NAME\fR | \fB\-\-analysis\-name\fR \fIANALYSIS_NAME\fR] [\fB\-\-apply\-filepath\-rewrites\fR | \fB\-\-no\-filepath\-rewrites\fR] [\fB\-s\fR \fISAMPLE_NAME\fR | \fB\-\-sample\-name\fR \fISAMPLE_NAME\fR] @@ -78,6 +79,11 @@ Load events through podio::DataSource\&. \fB\-\-output\-dir\fR \fIOUTPUT_DIR\fR Set global output directory for the analysis. .TP +\fB\-\-output\-format\fR {\fBttree\fR, \fBrntuple\fR} +Specify the output ROOT file format. +.br +Default value: \fBttree\fR. +.TP \fB\-a\fR \fIANALYSIS_NAME\fR, \fB\-\-analysis\-name\fR \fIANALYSIS_NAME\fR Set analysis name, mostly used for the output path. .TP @@ -124,7 +130,7 @@ Controls search path for the process dictionaries. The default value is \fI/cvmfs/fcc.cern.ch/FCCDicts/\fR\&. .SH SEE ALSO -fccanalysis(1), fccanalysis-script(7) +fccanalysis(1), fccanalysis-script(7), fccanalysis-fit(1), fccanalysis-extract(1) .SH BUGS Many diff --git a/man/man7/fccanalysis-script.7 b/man/man7/fccanalysis-script.7 index abbbd2d07c..69012ecfa1 100644 --- a/man/man7/fccanalysis-script.7 +++ b/man/man7/fccanalysis-script.7 @@ -90,6 +90,22 @@ These stages do not require \fIAnalysis\fR class neither \fIbuild_graph\fR function, they have their own set of attributes, please see the examples in the \fIexamples\fR of FCCAnalyses repository or fccanalysis-final-script(7) and fccanalysis-plots-script(7) manual pages. +.TP +\fBFit style analysis\fR +The analysis script needs to contain a \fIFit\fR class of the following +structure to generate Higgs Combine datacards: +.IP +class Fit(): + def __init__(self, cmdline_args): + self.datacard_dir = "outputs/datacards/" + self.combine_args = "\-M MultiDimFit \-\-algo grid" + self.processes = { + "signal": 0, + "background": 1 + } + self.channels = { + "channel_name": { ... } + } .SH ATTRIBUTES In case of running the FCCAnalysis in the staged style user can/should add the @@ -149,6 +165,11 @@ the sub-folders of $FCCDICTSDIR\&. \fBoutput_dir\fR (mandatory) User can specify the directory for the output files\&. .TP +\fBoutput_format\fR (optional) +Specifies the output ROOT file format. Accepted values are \fBttree\fR and \fBrntuple\fR. +.br +Default value: ttree +.TP \fBanalysis_name\fR (optional) Optional name for the analysis .br @@ -232,7 +253,7 @@ sample. This section is under construction. You are invited to help :) .SH SEE ALSO -fccanalysis(1), fccanalysis-run(1) +fccanalysis(1), fccanalysis-run(1), fccanalysis-fit(1), fccanalysis-extract(1) .SH BUGS Many From cd0f61b63f436d68215b88b11c0d60735711db4f Mon Sep 17 00:00:00 2001 From: Soumyadip Niyogi <70795242+captainvogon@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:15:17 +0000 Subject: [PATCH 33/36] update fccanalyses-extract man page --- man/man1/fccanalysis-extract.1 | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/man/man1/fccanalysis-extract.1 b/man/man1/fccanalysis-extract.1 index eb21ae2758..f3d34c7984 100644 --- a/man/man1/fccanalysis-extract.1 +++ b/man/man1/fccanalysis-extract.1 @@ -12,10 +12,9 @@ .SH DESCRIPTION .B fccanalysis\-extract -is a standalone utility that reads ROOT files generated by Higgs Combine (e.g., -\fIhiggsCombineTest.MultiDimFit.mH120.root\fR). It performs dynamic branch detection -to locate the Parameter of Interest (POI) and applies linear interpolation to -calculate and display the 1-sigma (68% CL) crossing uncertainties. +is a standalone utility that reads ROOT files or output directories generated by Higgs Combine. +If a directory path is provided, it automatically scans for generated output ROOT files (such as +\fIhiggsCombine*.root\fR or \fIfitDiagnostics*.root\fR) within that directory. It safely handles grid scans and gracefully falls back to displaying expected and observed quantiles if the file was generated using the AsymptoticLimits method. From f038f14dd5282cbf6c06c0c6757c6cb843d1eb79 Mon Sep 17 00:00:00 2001 From: Soumyadip Niyogi <70795242+captainvogon@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:18:22 +0000 Subject: [PATCH 34/36] extract searches for fitDiagnostics root files too --- python/extract.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/extract.py b/python/extract.py index 0731a732ac..4c7414c07e 100644 --- a/python/extract.py +++ b/python/extract.py @@ -34,7 +34,9 @@ def extract_fit_results(target_path): return if os.path.isdir(target_path): - root_files = glob.glob(os.path.join(target_path, "higgsCombine*.root")) + root_files = glob.glob(os.path.join(target_path, "higgsCombine*.root")) + \ + glob.glob(os.path.join(target_path, "fitDiagnostics*.root")) + if not root_files: LOGGER.warning("No higgsCombine output ROOT file found in directory: %s", target_path) return From ff463b51c6dfb5cb189a314a088ed794d4542ca5 Mon Sep 17 00:00:00 2001 From: Soumyadip Niyogi <70795242+captainvogon@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:26:48 +0000 Subject: [PATCH 35/36] add test for extract command --- tests/CMakeLists.txt | 6 ++-- tests/python/test_combine_backend.py | 53 ++++++++++++++++++---------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7bbabf1b7b..097976b2ea 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,15 +22,15 @@ if(WITH_PODIO_DATASOURCE) add_integration_test("examples/data_source/analysis_stage1.py") endif() -# Python Integration Tests for Combine Backend +# Python Integration Tests for Combine Backend (Fit & Extract) find_program(PYTHON_EXECUTABLE NAMES python3 python) if(PYTHON_EXECUTABLE) add_test( - NAME test_combine_fit_backend + NAME test_combine_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 WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + set_tests_properties(test_combine_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 6d7c6530f6..5aa776775c 100644 --- a/tests/python/test_combine_backend.py +++ b/tests/python/test_combine_backend.py @@ -8,7 +8,7 @@ REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..")) def test_combine_backend_execution(): - """Integration test to verify datacard generation and fit execution.""" + """Integration test to verify datacard generation, fit execution, and result extraction.""" # Anchor all relative tracks directly to the dynamic REPO_ROOT test_config = os.path.join(REPO_ROOT, "examples", "fcc_ee_zh_mumu_bb.py") @@ -26,14 +26,6 @@ def test_combine_backend_execution(): # 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 = [ - 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") @@ -44,22 +36,47 @@ def test_combine_backend_execution(): test_env["PYTHONPATH"] = f"{local_python_dir}:{local_bin_dir}:{current_python_path}" else: test_env["PYTHONPATH"] = f"{local_python_dir}:{local_bin_dir}" + + # Test `fccanalysis fit` + command_fit = [ + sys.executable, local_fccanalysis, "fit", test_config, + "-o", test_datacard, + "-e", + "--", "-M", "FitDiagnostics" + ] - print(f"----> Running verification command: {' '.join(command)}") - result = subprocess.run(command, capture_output=True, text=True, cwd=REPO_ROOT, env=test_env) + print(f"----> Running fit verification command: {' '.join(command_fit)}") + result_fit = subprocess.run(command_fit, 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.") - elif result.returncode != 0: - print(f"----> ERROR: Framework execution failed!\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}") + if result_fit.returncode == 6: + print("----> WARNING: 'combine' tool not found in this environment. Skipping fit & extraction execution test.") + sys.exit(0) + elif result_fit.returncode != 0: + print(f"----> ERROR: Framework fit execution failed!\nSTDOUT:\n{result_fit.stdout}\nSTDERR:\n{result_fit.stderr}") sys.exit(1) if not os.path.exists(test_datacard): print("----> ERROR: Datacard file asset was not generated successfully!") sys.exit(1) + print("----> INFO: Datacard generation & fit execution PASSED.") + + # Test `fccanalysis extract` + command_extract = [ + sys.executable, local_fccanalysis, "extract", test_output_dir + ] + + print(f"----> Running extraction verification command: {' '.join(command_extract)}") + result_extract = subprocess.run(command_extract, capture_output=True, text=True, cwd=REPO_ROOT, env=test_env) + + if result_extract.returncode != 0: + print(f"----> ERROR: Framework extraction execution failed!\nSTDOUT:\n{result_extract.stdout}\nSTDERR:\n{result_extract.stderr}") + sys.exit(1) + + print("----> INFO: Result extraction PASSED.") + if __name__ == "__main__": - print("----> INFO: Starting Combine backend integration test...") + print("----> INFO: Starting Combine backend integration test suite (fit + extract)...") test_combine_backend_execution() - print("----> INFO: Integration test PASSED successfully!") - sys.exit(0) + print("----> INFO: Integration test suite PASSED successfully!") + sys.exit(0) \ No newline at end of file From f3aaab31fe5473ccfcfe3118ee7cc01a749f3925 Mon Sep 17 00:00:00 2001 From: Soumyadip Niyogi <70795242+captainvogon@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:29:04 +0000 Subject: [PATCH 36/36] corrected fit.py artifact shifting --- python/fit.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/python/fit.py b/python/fit.py index b6b2351767..99ca07ba2a 100644 --- a/python/fit.py +++ b/python/fit.py @@ -5,6 +5,7 @@ import subprocess import shutil import glob +import shlex from extract import extract_fit_results LOGGER = logging.getLogger('FCCAnalyses.fit') @@ -25,7 +26,6 @@ def run_fit(parser: argparse.ArgumentParser) -> None: if backend == 'combine': from combine import generate_datacard - import shlex class_combine_args = generate_datacard(anapath, output_path) @@ -36,26 +36,26 @@ def run_fit(parser: argparse.ArgumentParser) -> None: LOGGER.info('Launching Combine statistical engine execution on: %s', output_path) try: - full_command = [] if tool_args and tool_args[0] == '--': tool_args = tool_args[1:] cleaned_args = [arg for arg in tool_args if arg != output_path] class_args = shlex.split(class_combine_args) if class_combine_args else [] + # Strip class-level method flags if CLI explicitly passes a method override 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: + while flag in class_args: idx = class_args.index(flag) - del class_args[idx:idx+2] + del class_args[idx:idx+2] combined_args = class_args + cleaned_args if not any('MINIMIZER_analytic' in arg for arg in combined_args): combined_args = ['--X-rtd', 'MINIMIZER_analytic'] + combined_args - # Build the command WITHOUT forcing --out + # Build the final command vector if '-M' in combined_args or '--method' in combined_args: full_command = ['combine'] + combined_args + [output_path] else: @@ -65,19 +65,22 @@ def run_fit(parser: argparse.ArgumentParser) -> None: subprocess.run(full_command, check=True) # --- ROBUST ARTIFACT SHIFTING --- - # Scoop up any ROOT files Combine dropped in the main directory + # Scoop up any ROOT files Combine dropped in the working directory artifacts = glob.glob("higgsCombine*.root") + glob.glob("fitDiagnostics*.root") for artifact in artifacts: - dest_file = os.path.join(output_dir, os.path.basename(artifact)) - # Remove old versions if they exist in the target directory - if os.path.exists(dest_file): - os.remove(dest_file) - shutil.move(artifact, output_dir) + src_file = os.path.abspath(artifact) + dest_file = os.path.abspath(os.path.join(output_dir, os.path.basename(artifact))) + + # Only move/replace if source and destination paths differ + if src_file != dest_file: + if os.path.exists(dest_file): + os.remove(dest_file) + shutil.move(src_file, output_dir) - # Automatically extract and display results from the correct directory + # Automatically extract and display results from the target output directory extract_fit_results(output_dir) - except subprocess.CalledProcessError as e: + except subprocess.CalledProcessError: LOGGER.error('Combine statistical fitting execution failed!') sys.exit(7) @@ -86,4 +89,4 @@ def run_fit(parser: argparse.ArgumentParser) -> None: sys.exit(0) else: LOGGER.error('Backend "%s" is not implemented yet. Supported backends: combine.', backend) - sys.exit(4) + sys.exit(4) \ No newline at end of file