From 5721641829cbaf5c40898f630a7da2c8ca28b7bc Mon Sep 17 00:00:00 2001 From: Wopke Telman Date: Thu, 19 Feb 2026 14:34:14 +0100 Subject: [PATCH 1/4] Add closure-truth vertical lines to posterior plots --- src/smefit/analyze/coefficients_utils.py | 76 +++++++++++++++++++++++- src/smefit/analyze/report.py | 19 ++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/smefit/analyze/coefficients_utils.py b/src/smefit/analyze/coefficients_utils.py index a1421c59..dd793776 100644 --- a/src/smefit/analyze/coefficients_utils.py +++ b/src/smefit/analyze/coefficients_utils.py @@ -680,6 +680,32 @@ def compute_rows_and_columns(self, nrows=-1, ncols=-1): return nrows, ncols + @staticmethod + def _extract_point_value(point_values, coeff_name): + """Return per-coefficient value from common container types.""" + if point_values is None: + return None + + try: + if isinstance(point_values, pd.DataFrame): + if coeff_name in point_values.columns: + return float(point_values[coeff_name].iloc[0]) + return None + + if isinstance(point_values, pd.Series): + if coeff_name in point_values.index: + return float(point_values[coeff_name]) + return None + + if isinstance(point_values, dict): + if coeff_name in point_values: + return float(point_values[coeff_name]) + return None + except (TypeError, ValueError): + return None + + return None + def plot_posteriors(self, posteriors, labels, **kwargs): """Plot posteriors histograms. @@ -691,8 +717,25 @@ def plot_posteriors(self, posteriors, labels, **kwargs): list of fit names kwargs: dict keyword arguments for the plot + Supported keys: + - show_closure_truth: bool + - closure_truth_points: list[dict | pd.Series | pd.DataFrame] + - closure_line_color: str + - closure_line_style: str + - closure_line_width: float + - closure_line_alpha: float + - show_closure_legend: bool + - closure_line_label: str """ colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] + show_closure_truth = kwargs.get("show_closure_truth", False) + closure_truth_points = kwargs.get("closure_truth_points", None) + closure_line_color = kwargs.get("closure_line_color", "black") + closure_line_style = kwargs.get("closure_line_style", "--") + closure_line_width = kwargs.get("closure_line_width", 1.6) + closure_line_alpha = kwargs.get("closure_line_alpha", 0.9) + show_closure_legend = kwargs.get("show_closure_legend", False) + closure_line_label = kwargs.get("closure_line_label", "Closure truth") nrows, ncols = self.compute_rows_and_columns( kwargs.get("nrows", -1), kwargs.get("ncols", -1) @@ -739,6 +782,22 @@ def plot_posteriors(self, posteriors, labels, **kwargs): alpha=0.3, label=labels[clr_idx], ) + + if show_closure_truth and closure_truth_points is not None: + if clr_idx < len(closure_truth_points): + closure_value = self._extract_point_value( + closure_truth_points[clr_idx], l + ) + if closure_value is not None: + ax.axvline( + closure_value, + color=closure_line_color, + linestyle=closure_line_style, + linewidth=closure_line_width, + alpha=closure_line_alpha, + zorder=11, + ) + ax.text( 0.05, 0.85, @@ -754,6 +813,21 @@ def plot_posteriors(self, posteriors, labels, **kwargs): if len(axes.get_legend_handles_labels()[0]) > len(lines): lines, labels = axes.get_legend_handles_labels() + lines = list(lines) + labels = list(labels) + if show_closure_truth and show_closure_legend and closure_line_label not in labels: + lines.append( + mlines.Line2D( + [], + [], + color=closure_line_color, + linestyle=closure_line_style, + linewidth=closure_line_width, + alpha=closure_line_alpha, + ) + ) + labels.append(closure_line_label) + # fontsize is normalised to 25 for 5 columns and subplot size 4 legend_font_size = 25 * (ncols * subplot_size) / 20 legend_font_size_inch = legend_font_size / 72 # 72 pt = 1 inch @@ -761,7 +835,7 @@ def plot_posteriors(self, posteriors, labels, **kwargs): fig.legend( lines, labels, - ncol=len(posteriors), + ncol=len(labels), prop={"size": legend_font_size}, bbox_to_anchor=(0.5, 1.0), loc="upper center", diff --git a/src/smefit/analyze/report.py b/src/smefit/analyze/report.py index 469398e9..7b996c7d 100644 --- a/src/smefit/analyze/report.py +++ b/src/smefit/analyze/report.py @@ -365,10 +365,29 @@ def coefficients( for fit in self.fits ] posterior_histograms["disjointed_lists"] = disjointed_lists + closure_truth_points = posterior_histograms.pop( + "closure_truth_points", None + ) + if closure_truth_points is None: + closure_truth_points = [] + coeff_names = coeff_plt.coeff_info.index.get_level_values(1) + for fit in self.fits: + fit_truth = {} + coeff_cfg = fit.config.get("coefficients", {}) + for coeff_name in coeff_names: + coeff_entry = coeff_cfg.get(coeff_name, {}) + if isinstance(coeff_entry, dict): + fit_truth[coeff_name] = coeff_entry.get("value", 0.0) + elif isinstance(coeff_entry, (int, float, np.floating)): + fit_truth[coeff_name] = float(coeff_entry) + else: + fit_truth[coeff_name] = 0.0 + closure_truth_points.append(fit_truth) coeff_plt.plot_posteriors( [fit.results["samples"] for fit in self.fits], labels=[fit.label for fit in self.fits], + closure_truth_points=closure_truth_points, **posterior_histograms, ) figs_list.append("coefficient_histo") From 5b47d80659ff8d603d864edfddff5c4196cae666 Mon Sep 17 00:00:00 2001 From: Wopke Telman Date: Fri, 20 Feb 2026 11:02:31 +0100 Subject: [PATCH 2/4] Apply pre-commit formatting --- src/smefit/analyze/coefficients_utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/smefit/analyze/coefficients_utils.py b/src/smefit/analyze/coefficients_utils.py index dd793776..4029837f 100644 --- a/src/smefit/analyze/coefficients_utils.py +++ b/src/smefit/analyze/coefficients_utils.py @@ -815,7 +815,11 @@ def plot_posteriors(self, posteriors, labels, **kwargs): lines = list(lines) labels = list(labels) - if show_closure_truth and show_closure_legend and closure_line_label not in labels: + if ( + show_closure_truth + and show_closure_legend + and closure_line_label not in labels + ): lines.append( mlines.Line2D( [], From cd10057bdfb6e1dc518c32b18eeaba8866a2eb20 Mon Sep 17 00:00:00 2001 From: Wopke Telman Date: Fri, 20 Feb 2026 13:55:30 +0100 Subject: [PATCH 3/4] Added test runcard closure lines --- .../analyze/test_runcard_closure_lines.yaml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 runcards/analyze/test_runcard_closure_lines.yaml diff --git a/runcards/analyze/test_runcard_closure_lines.yaml b/runcards/analyze/test_runcard_closure_lines.yaml new file mode 100644 index 00000000..1aef312c --- /dev/null +++ b/runcards/analyze/test_runcard_closure_lines.yaml @@ -0,0 +1,30 @@ +name: "Report_test_closure_lines" +title: "Closure Line Test" + +result_IDs: [SM_G23] +fit_labels: [SM G23] + +report_path: /data/theorie/wtelman/reports/benchmark +result_path: /data/theorie/wtelman/results/benchmark + +summary: False + +coefficients_plots: + posterior_histograms: + nrows: -1 + disjointed_lists: False + bins: 40 + show_closure_truth: True + closure_line_style: "--" + closure_line_color: red + show_closure_legend: True + closure_line_label: "Closure truth" + logo: False + +coeff_info: + default: + - [OpBox, "$O_{Box}$"] + +data_info: + default: + - [FCCee_ww_161GeV, "WW @ 161 GeV"] From b8b22c6e8819da00ab6ae526d4ae46596725ab62 Mon Sep 17 00:00:00 2001 From: Wopke Telman Date: Mon, 2 Mar 2026 10:25:09 +0100 Subject: [PATCH 4/4] Can now read truth directly via projection --- src/smefit/analyze/report.py | 107 ++++++++++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 13 deletions(-) diff --git a/src/smefit/analyze/report.py b/src/smefit/analyze/report.py index 7b996c7d..f9345637 100644 --- a/src/smefit/analyze/report.py +++ b/src/smefit/analyze/report.py @@ -4,7 +4,9 @@ import numpy as np import pandas as pd +import yaml +from ..coefficients import CoefficientManager from ..fit_manager import FitManager from ..log import logging from .chi2_utils import Chi2tableCalculator @@ -142,6 +144,80 @@ def _append_section(self, title, links=None, figs=None, tables=None): title, links=links, figs=figs, dataFrame=tables ) + @staticmethod + def _compute_closure_truth_from_coeff_config(coeff_cfg, coeff_names): + """Compute closure-truth Wilson coefficients from a coefficient config.""" + fit_truth = {coeff_name: 0.0 for coeff_name in coeff_names} + if not coeff_cfg: + return fit_truth + + coefficients = CoefficientManager.from_dict(copy.deepcopy(coeff_cfg)) + if not coefficients.free_parameters.empty: + # Free parameters have no closure-truth value by construction. + coefficients.set_free_parameters( + np.zeros(coefficients.free_parameters.shape[0], dtype=float) + ) + coefficients.set_constraints() + + coeff_set = set(coefficients.name) + for coeff_name in coeff_names: + if coeff_name not in coeff_set: + continue + coeff_entry = coeff_cfg.get(coeff_name, {}) + if isinstance(coeff_entry, (int, float, np.floating)): + fit_truth[coeff_name] = float(coeff_entry) + elif isinstance(coeff_entry, dict) and ( + "value" in coeff_entry or "constrain" in coeff_entry + ): + fit_truth[coeff_name] = float(coefficients[coeff_name]["value"]) + return fit_truth + + def _load_closure_truth_points_from_runcards( + self, closure_truth_runcards, coeff_names + ): + """Load closure-truth values from one or more projection/run runcards.""" + if isinstance(closure_truth_runcards, dict): + runcard_paths = [] + for fit in self.fits: + fit_runcard = closure_truth_runcards.get(fit.name, None) + if fit_runcard is None: + raise ValueError( + "closure_truth_runcards is missing an entry for " + f"result_ID '{fit.name}'." + ) + runcard_paths.append(fit_runcard) + elif isinstance(closure_truth_runcards, (str, pathlib.Path)): + runcard_paths = [closure_truth_runcards for _ in self.fits] + else: + runcard_paths = list(closure_truth_runcards) + if len(runcard_paths) == 1 and len(self.fits) > 1: + runcard_paths = [runcard_paths[0] for _ in self.fits] + elif len(runcard_paths) != len(self.fits): + raise ValueError( + "closure_truth_runcards must have either 1 entry or one entry per " + f"fit ({len(self.fits)}). Got {len(runcard_paths)} entries." + ) + + closure_truth_points = [] + for runcard in runcard_paths: + runcard_path = pathlib.Path(runcard).expanduser() + with open(runcard_path, encoding="utf-8") as f: + runcard_config = yaml.safe_load(f) + coeff_cfg = runcard_config.get("coefficients", {}) + closure_truth_points.append( + self._compute_closure_truth_from_coeff_config(coeff_cfg, coeff_names) + ) + return closure_truth_points + + def _load_closure_truth_points_from_fit_configs(self, coeff_names): + closure_truth_points = [] + for fit in self.fits: + coeff_cfg = fit.config.get("coefficients", {}) + closure_truth_points.append( + self._compute_closure_truth_from_coeff_config(coeff_cfg, coeff_names) + ) + return closure_truth_points + def summary(self): """Summary Table runner.""" summary = SummaryWriter(self.fits, self.data_info, self.coeff_info) @@ -244,6 +320,11 @@ def coefficients( kwarg scatter plot or None posterior_histograms: bool if True plot the posterior distribution for each coefficient + Additional supported keys in the `posterior_histograms` block: + - closure_truth_points: explicit list of dict values per fit. + - closure_truth_runcards: path/list/dict to projection (or run) runcard(s) + used to generate closure pseudo-data. Values are evaluated from the + `coefficients` block, including constrained relations. table: None, dict kwarg the latex confidence level table per coefficient or None double_solution: dict @@ -368,21 +449,21 @@ def coefficients( closure_truth_points = posterior_histograms.pop( "closure_truth_points", None ) + closure_truth_runcards = posterior_histograms.pop( + "closure_truth_runcards", None + ) if closure_truth_points is None: - closure_truth_points = [] coeff_names = coeff_plt.coeff_info.index.get_level_values(1) - for fit in self.fits: - fit_truth = {} - coeff_cfg = fit.config.get("coefficients", {}) - for coeff_name in coeff_names: - coeff_entry = coeff_cfg.get(coeff_name, {}) - if isinstance(coeff_entry, dict): - fit_truth[coeff_name] = coeff_entry.get("value", 0.0) - elif isinstance(coeff_entry, (int, float, np.floating)): - fit_truth[coeff_name] = float(coeff_entry) - else: - fit_truth[coeff_name] = 0.0 - closure_truth_points.append(fit_truth) + if closure_truth_runcards is not None: + closure_truth_points = ( + self._load_closure_truth_points_from_runcards( + closure_truth_runcards, coeff_names + ) + ) + else: + closure_truth_points = ( + self._load_closure_truth_points_from_fit_configs(coeff_names) + ) coeff_plt.plot_posteriors( [fit.results["samples"] for fit in self.fits],