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"] diff --git a/src/smefit/analyze/coefficients_utils.py b/src/smefit/analyze/coefficients_utils.py index a1421c59..4029837f 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,25 @@ 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 +839,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..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 @@ -365,10 +446,29 @@ def coefficients( for fit in self.fits ] posterior_histograms["disjointed_lists"] = disjointed_lists + 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: + coeff_names = coeff_plt.coeff_info.index.get_level_values(1) + 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], labels=[fit.label for fit in self.fits], + closure_truth_points=closure_truth_points, **posterior_histograms, ) figs_list.append("coefficient_histo")