From 67257ed724ebab5efba2b1f551f3faf8c69c9ba2 Mon Sep 17 00:00:00 2001 From: Anton Riedel Date: Sun, 23 Aug 2026 10:16:46 +0200 Subject: [PATCH 1/3] Feat: add systematic variation export to cutculator gui --- PWGCF/Femto/Macros/cutculator_gui.py | 347 ++++++++++++++++++++++++--- 1 file changed, 307 insertions(+), 40 deletions(-) diff --git a/PWGCF/Femto/Macros/cutculator_gui.py b/PWGCF/Femto/Macros/cutculator_gui.py index e70b5b8f6c7..bc393cc3232 100644 --- a/PWGCF/Femto/Macros/cutculator_gui.py +++ b/PWGCF/Femto/Macros/cutculator_gui.py @@ -19,6 +19,9 @@ import tkinter as tk from tkinter import ttk, filedialog, messagebox import argparse +import itertools +import json +import os from typing import Any, Dict, List try: @@ -31,6 +34,10 @@ VALUE_DELIM = "___" SECTION_DELIM = ":::" +# Above this number of enumerated variations the export asks for confirmation +# before building the list (the product over cuts grows multiplicatively). +VARIATION_WARN_LIMIT = 5000 + # ── Colours ────────────────────────────────────────────────────────────────── BG = "#1e1e2e" BG_CARD = "#2a2a3e" @@ -74,6 +81,12 @@ def format_value_with_comment(b): return val +def bit_position_int(b): + """BitPosition as an int, or -1 for the always-applied (X) bins.""" + pos = b.get("BitPosition", "X") + return int(pos) if pos.upper() != "X" else -1 + + def bin_type(b): """Return 'minimal', 'optional', 'neutral', or 'skip'.""" is_min = b.get("MinimalCut", "0") == "1" @@ -88,6 +101,21 @@ def bin_type(b): return "neutral" +def variation_kind(b): + """Axis kind used for grouping systematic variations. + + Identical to bin_type() except that an always-applied minimal floor + (BitPosition=="X", which bin_type() calls 'skip') is folded onto the + 'minimal' axis of its selection: it is the loosest rung of the same + threshold ladder, so it is a legitimate variation point alongside the + stricter, bit-carrying rungs — it just contributes no bit. + """ + kind = bin_type(b) + if kind == "skip" and b.get("MinimalCut", "0") == "1" and b.get("OptionalCut", "0") == "0": + return "minimal" + return kind + + def has_only_skipped_minimal_bins(group): """ Returns True when a group has at least one minimal bin but ALL of them have @@ -99,23 +127,27 @@ def has_only_skipped_minimal_bins(group): return bool(minimal) and all(b.get("BitPosition", "X").upper() == "X" for b in minimal) -def get_skipped_minimal_bins(group): +def get_skipped_minimal_bin_items(group): """ - Returns the minimal bins in a group whose BitPosition=="X" — i.e. the - loosest threshold(s) that are always in effect regardless of which - (if any) stricter minimal bit the user additionally selects. These never - get a checkbox, but their effect is always applied, so they must still - be surfaced in the summary. + Returns (index_in_group, bin) for the minimal bins whose BitPosition=="X" — + i.e. the loosest threshold(s), always in effect unless a stricter minimal + bit is set. They carry no bit, but they are still a selectable variation + point (see variation_kind), so the index is needed to key self._vars. """ return [ - b - for b in group + (i, b) + for i, b in enumerate(group) if b.get("MinimalCut", "0") == "1" and b.get("OptionalCut", "0") == "0" and b.get("BitPosition", "X").upper() == "X" ] +def get_skipped_minimal_bins(group): + """The bins from get_skipped_minimal_bin_items(), without their indices.""" + return [b for _i, b in get_skipped_minimal_bin_items(group)] + + def is_filter_group(bins): """ Determine whether the parsed bins belong to a filter histogram (fixed, @@ -154,6 +186,32 @@ def load_bins_from_hist(hist): return groups, False +def resolve_file_path(path): + """Absolute, ~-expanded path for a local file. + + Remote specifications (root://, alien://, http://, …) are returned + untouched — os.path.abspath() would prepend the cwd and corrupt them. + """ + if "://" in path: + return path + return os.path.abspath(os.path.expanduser(path)) + + +def unique_key(mapping, base): + """Return `base`, or `base #2`, `base #3`, … if it is already taken. + + Needed because two different axes (or an axis and an always-applied floor) + can carry the same SelectionName; JSON objects cannot hold duplicate keys, + so the collision has to be resolved rather than silently overwritten. + """ + if base not in mapping: + return base + n = 2 + while f"{base} #{n}" in mapping: + n += 1 + return f"{base} #{n}" + + # ── Main Application ────────────────────────────────────────────────────────── class CutCulatorApp(tk.Tk): def __init__(self, rootfile=None, tdir="femto-producer"): @@ -231,6 +289,10 @@ def _build_ui(self): self._lbl_bin = tk.Label(self._bottom, text="—", font=FONT_MONO, bg=BG_CARD, fg=FG_DIM, anchor="w") self._lbl_bin.pack(side="left", padx=8) + # live count of enumerated systematic variations for the current checkboxes + self._lbl_var_count = tk.Label(self._bottom, text="", font=FONT_SMALL, bg=BG_CARD, fg=ACCENT_ALWAYS, anchor="w") + self._lbl_var_count.pack(side="left", padx=12) + copy_frame = tk.Frame(self._bottom, bg=BG_CARD) copy_frame.pack(side="right") self._make_button(copy_frame, "Copy Dec", lambda: self._copy(self._lbl_dec["text"]), ACCENT).pack( @@ -239,6 +301,9 @@ def _build_ui(self): self._make_button(copy_frame, "Copy Hex", lambda: self._copy(self._lbl_hex["text"]), ACCENT_OPT).pack( side="left", padx=3 ) + self._make_button(copy_frame, "Export variations", self._export_variations, ACCENT_ALWAYS).pack( + side="left", padx=3 + ) # ── main paned area: selection cards (left) + summary sidebar (right) ── self._paned = tk.PanedWindow(self, orient="horizontal", bg=BORDER, sashwidth=4, sashrelief="flat") @@ -370,7 +435,13 @@ def _load_file(self, path): messagebox.showerror("Error", f"Cannot open ROOT file:\n{path}") return self._root_file = f - self._lbl_file.config(text=path) + # Keep the attribute in sync with what is actually loaded — it was + # previously only ever set from the CLI argument, so a file opened via + # the dialog left it pointing at the old (or None) path. Resolved to an + # absolute path so a relative CLI argument (./AnalysisResults.root) still + # gives a reproducible provenance record in the exported JSON. + self._rootfile_path = resolve_file_path(path) + self._lbl_file.config(text=self._rootfile_path) d = f.Get(self._tdir_path) if not d: @@ -403,6 +474,7 @@ def _on_hist_selected(self, _e=None): # Always start from a clean summary panel — forget()-ing the pane only # hides it, it does not destroy previously built rows. self._clear_summary() + self._lbl_var_count.config(text="") parsed, is_filter = load_bins_from_hist(hist) self._is_filter_hist = is_filter @@ -578,15 +650,18 @@ def _build_group_card(self, sel_name, group): bins_frame.pack(fill="x", padx=6, pady=4) if loosest_only: - # Display loosest minimal bins as informational rows — no checkbox, no bit + # The whole cut is a single always-true rung — there is no stricter + # alternative to vary against, so nothing to check: informational only. for b in group: if b.get("MinimalCut", "0") == "1" and b.get("OptionalCut", "0") == "0": - self._build_loosest_row(bins_frame, b) + self._build_loosest_row(bins_frame, None, None, b) else: - # even when there are selectable minimal bins, the skipped-bit-position - # loosest bin(s) are still always in effect — show them as informational rows too - for b in get_skipped_minimal_bins(group): - self._build_loosest_row(bins_frame, b) + # The loosest bin is always in effect when no stricter minimal bit is + # set — which makes it the loosest rung of the ladder and therefore a + # variation point in its own right. It gets a checkbox like any other + # rung; it just contributes no bit when picked. + for i, b in get_skipped_minimal_bin_items(group): + self._build_loosest_row(bins_frame, sel_name, i, b) for i, b in minimal: self._build_bin_row(bins_frame, sel_name, i, b, "minimal") for i, b in optional: @@ -594,33 +669,59 @@ def _build_group_card(self, sel_name, group): for i, b in neutral: self._build_bin_row(bins_frame, sel_name, i, b, "neutral") - def _build_loosest_row(self, parent, b): - """Informational row for a loosest/always-true cut — no checkbox, no bit. + def _build_loosest_row(self, parent, sel_name, idx, b): + """Row for a loosest/always-true cut — never sets a bit. - Uses the exact same widget type/width (a Label with width=CHECK_COL_WIDTH) - for its leading column as _build_bin_row's checkbox glyph, so the two row + With sel_name/idx given the row is checkable, so the loosest rung can be + included as a systematic variation; with both None it is purely + informational (used when the cut has no stricter rung to vary against). + + Either way the leading column is the same widget type/width (a Label with + width=CHECK_COL_WIDTH) as _build_bin_row's checkbox glyph, so all row types line up pixel-for-pixel. A native tk.Checkbutton's indicator box has no queryable pixel width, so matching it with a plain spacer Label never - aligns reliably — using an identical Label on both sides is the only way - to guarantee it. + aligns reliably — using an identical Label everywhere is the only way to + guarantee it. """ label_text = format_value_with_comment(b) + checkable = sel_name is not None and idx is not None - row = tk.Frame(parent, bg=BG_CARD) + row = tk.Frame(parent, bg=BG_CARD, cursor="hand2" if checkable else "") row.pack(fill="x", pady=1) tk.Label(row, text="●", font=FONT_BODY, bg=BG_CARD, fg=ACCENT_ALWAYS).pack(side="left", padx=(0, 4)) - tk.Label(row, text=" ", font=FONT_BODY, bg=BG_CARD, fg=FG_DIM, width=CHECK_COL_WIDTH, anchor="w").pack( - side="left" - ) - tk.Label( + + check_lbl = tk.Label( row, - text=label_text, + text="[ ]" if checkable else " ", font=FONT_BODY, bg=BG_CARD, fg=FG_DIM, + width=CHECK_COL_WIDTH, anchor="w", - ).pack(side="left", fill="x", expand=True) + ) + check_lbl.pack(side="left") + + text_lbl = tk.Label(row, text=label_text, font=FONT_BODY, bg=BG_CARD, fg=FG_DIM, anchor="w") + text_lbl.pack(side="left", fill="x", expand=True) + + tk.Label(row, text="no bit", font=FONT_SMALL, bg=BG_CARD, fg=ACCENT_ALWAYS, width=8).pack(side="right", padx=4) + + if not checkable: + return + + var = tk.BooleanVar(value=False) + self._vars[(sel_name, idx)] = var + self._check_labels[(sel_name, idx)] = check_lbl + + def toggle(_e=None): + var.set(not var.get()) + check_lbl.config(text="[x]" if var.get() else "[ ]", fg=ACCENT_ALWAYS if var.get() else FG_DIM) + text_lbl.config(fg=FG if var.get() else FG_DIM) + self._update_bitmask() + + for w in (row, check_lbl, text_lbl): + w.bind("", toggle) def _build_bin_row(self, parent, sel_name, idx, b, kind): color = {"minimal": ACCENT, "optional": ACCENT_OPT, "neutral": ACCENT_REJ}[kind] @@ -665,36 +766,44 @@ def _update_bitmask(self): return bitmask = 0 - checked = [] # (sel_name, kind, pos_int, label_text, pos_str, color) + checked = [] # (sel_name, kind, pos_int, label_text, pos_str, color, is_loosest) for (sel_name, idx), var in self._vars.items(): if not var.get(): continue b = self._groups[sel_name][idx] pos = b.get("BitPosition", "X") - kind = bin_type(b) - color = {"minimal": ACCENT, "optional": ACCENT_OPT, "neutral": ACCENT_REJ}.get(kind, FG_DIM) - - if pos.upper() != "X": + # a checked loosest rung sets no bit; variation_kind still files it + # under its selection's minimal axis + is_loosest = pos.upper() == "X" + kind = variation_kind(b) + color = ( + ACCENT_ALWAYS + if is_loosest + else {"minimal": ACCENT, "optional": ACCENT_OPT, "neutral": ACCENT_REJ}.get(kind, FG_DIM) + ) + + if not is_loosest: bitmask |= 1 << int(pos) - pos_int = int(pos) if pos.upper() != "X" else -1 label_text = format_value_with_comment(b) - checked.append((sel_name, kind, pos_int, label_text, pos, color)) + checked.append((sel_name, kind, bit_position_int(b), label_text, pos, color, is_loosest)) # Within the same (sel_name, kind) group, multiple checked bits represent a # threshold ladder for one observable, not independent alternatives — only # the strictest one (highest BitPosition) is the meaningful cut, so collapse - # to that single entry for display. + # to that single entry for display. The loosest rung sorts to -1 and so + # loses to any bit-carrying rung of the same ladder, which is correct: the + # bitmask bar shows one working point, not the scan. tightest_by_group = {} - for sel_name, kind, pos_int, label_text, pos, color in checked: + for sel_name, kind, pos_int, label_text, pos, color, is_loosest in checked: key = (sel_name, kind) if key not in tightest_by_group or pos_int > tightest_by_group[key][0]: - tightest_by_group[key] = (pos_int, label_text, pos, color) + tightest_by_group[key] = (pos_int, label_text, pos, color, is_loosest) selected_entries = [ - (sel_name, label_text, pos, color, False) - for (sel_name, kind), (pos_int, label_text, pos, color) in tightest_by_group.items() + (sel_name, label_text, pos, color, is_loosest) + for (sel_name, kind), (pos_int, label_text, pos, color, is_loosest) in tightest_by_group.items() ] # Selection names that already have a checked "minimal" entry — for those, @@ -722,6 +831,7 @@ def _update_bitmask(self): self._lbl_bin.config(text=bin(bitmask)) self._rebuild_summary(selected_entries) + self._update_variation_count() def _rebuild_summary(self, entries): """Rebuild the selected-cuts summary sidebar. Narrow column → each value @@ -791,6 +901,163 @@ def _rebuild_summary(self, entries): self._on_summary_inner_configure() + # ── Systematic variations ───────────────────────────────────────────────── + def _variation_axes(self): + """Group the currently checked bins into one variation axis per + (SelectionName, kind). + + Semantics: within one axis the checked bins are treated as MUTUALLY + EXCLUSIVE alternatives — each one is a separate systematic variation of + the same observable, not an additional cut applied on top. This holds for + minimal and optional cuts alike, and includes a checked loosest rung + (BitPosition=="X"), which is just the variation that sets no bit. + + Returns an ordered dict {(sel_name, kind): [bin_dict, ...]}, each list + sorted by ascending BitPosition (loosest → strictest). Insertion order + follows the card order on screen, so the enumeration is reproducible. + """ + axes: Dict[Any, List[Dict[str, Any]]] = {} + for (sel_name, idx), var in self._vars.items(): + if not var.get(): + continue + b = self._groups[sel_name][idx] + axes.setdefault((sel_name, variation_kind(b)), []).append(b) + + for bins in axes.values(): + bins.sort(key=bit_position_int) + return axes + + @staticmethod + def _axis_key_labels(axes): + """JSON key for each axis: the plain SelectionName, disambiguated with the + cut kind only when the same SelectionName carries more than one axis.""" + counts: Dict[str, int] = {} + for sel_name, _kind in axes: + counts[sel_name] = counts.get(sel_name, 0) + 1 + return {(sel_name, kind): (sel_name if counts[sel_name] == 1 else f"{sel_name} [{kind}]") for sel_name, kind in axes} + + def _always_applied_bins(self, axes): + """The BitPosition==X minimal floors that are in effect for every variation. + + Skipped for any SelectionName that has a checked minimal axis: either the + picked rung sits at a higher bit position and is therefore strictly + tighter (floor subsumed), or the floor itself is the picked rung and is + already carried by the axis. Emitting it here too would duplicate the key. + """ + sel_with_minimal_axis = {sel_name for sel_name, kind in axes if kind == "minimal"} + out = [] + for sel_name, group in self._groups.items(): + if sel_name in sel_with_minimal_axis: + continue + for b in get_skipped_minimal_bins(group): + out.append((sel_name, b)) + return out + + def _variation_count(self, axes=None): + axes = self._variation_axes() if axes is None else axes + if not axes: + return 0 + n = 1 + for bins in axes.values(): + n *= len(bins) + return n + + def _update_variation_count(self): + n = self._variation_count() + if n <= 1: + # 0 = nothing checked, 1 = a single fixed working point (no variation) + self._lbl_var_count.config(text="") + else: + self._lbl_var_count.config(text=f"→ {n} variations") + + def _compute_variations(self): + """Cartesian product over all variation axes → one dict per combination.""" + axes = self._variation_axes() + if not axes: + return [] + + labels = self._axis_key_labels(axes) + axis_items = list(axes.items()) + floors = self._always_applied_bins(axes) + + variations = [] + for combo in itertools.product(*[bins for _key, bins in axis_items]): + bitmask = 0 + entry: Dict[str, Any] = {} + for (key, _bins), b in zip(axis_items, combo): + pos = b.get("BitPosition", "X") + if pos.upper() != "X": + bitmask |= 1 << int(pos) + entry[unique_key(entry, labels[key])] = b.get("Value", "") + + # floors carry no bit but define the cut actually in effect, so they + # belong in the record of each variation + for sel_name, b in floors: + base = sel_name if sel_name not in entry else f"{sel_name} [floor]" + entry[unique_key(entry, base)] = b.get("Value", "") + + variations.append( + { + "index": len(variations), + "bitmask": bitmask, + "bitmask_hex": hex(bitmask), + **entry, + } + ) + return variations + + def _export_variations(self): + if self._is_filter_hist: + messagebox.showinfo( + "Not applicable", + "This is a filter histogram — its values are fixed and carry no bits, so there is nothing to vary.", + ) + return + + axes = self._variation_axes() + if not axes: + messagebox.showwarning( + "Nothing selected", + "Check the options you want to scan first.\n\n" + "Options checked within the same cut are enumerated as alternative " + "variations; the export is the product across all cuts.", + ) + return + + n = self._variation_count(axes) + if n > VARIATION_WARN_LIMIT: + if not messagebox.askyesno("Many variations", f"This will enumerate {n} variations. Continue?"): + return + + variations = self._compute_variations() + + path = filedialog.asksaveasfilename( + title="Save systematic variations", + defaultextension=".json", + initialfile="cut_variations.json", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + ) + if not path: + return + + payload = { + "file": self._rootfile_path, + "directory": self._tdir_path, + "histogram": self._hist_var.get(), + "n_variations": len(variations), + "variations": variations, + } + + try: + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + fh.write("\n") + except OSError as exc: + messagebox.showerror("Error", f"Could not write file:\n{exc}") + return + + messagebox.showinfo("Exported", f"Wrote {len(variations)} variations to\n{path}") + # ── Utilities ───────────────────────────────────────────────────────────── def _copy(self, text): self.clipboard_clear() From 8ae92907627de351b4262a9e40feecbec046b728 Mon Sep 17 00:00:00 2001 From: Anton Riedel Date: Sun, 23 Aug 2026 10:32:42 +0200 Subject: [PATCH 2/3] Fix: fix linter warning --- PWGCF/Femto/Macros/cutculator_gui.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) mode change 100644 => 100755 PWGCF/Femto/Macros/cutculator_gui.py diff --git a/PWGCF/Femto/Macros/cutculator_gui.py b/PWGCF/Femto/Macros/cutculator_gui.py old mode 100644 new mode 100755 index bc393cc3232..039908fa158 --- a/PWGCF/Femto/Macros/cutculator_gui.py +++ b/PWGCF/Femto/Macros/cutculator_gui.py @@ -934,7 +934,9 @@ def _axis_key_labels(axes): counts: Dict[str, int] = {} for sel_name, _kind in axes: counts[sel_name] = counts.get(sel_name, 0) + 1 - return {(sel_name, kind): (sel_name if counts[sel_name] == 1 else f"{sel_name} [{kind}]") for sel_name, kind in axes} + return { + (sel_name, kind): (sel_name if counts[sel_name] == 1 else f"{sel_name} [{kind}]") for sel_name, kind in axes + } def _always_applied_bins(self, axes): """The BitPosition==X minimal floors that are in effect for every variation. @@ -1025,9 +1027,10 @@ def _export_variations(self): return n = self._variation_count(axes) - if n > VARIATION_WARN_LIMIT: - if not messagebox.askyesno("Many variations", f"This will enumerate {n} variations. Continue?"): - return + if n > VARIATION_WARN_LIMIT and not messagebox.askyesno( + "Many variations", f"This will enumerate {n} variations. Continue?" + ): + return variations = self._compute_variations() From 6e44d429e9f9e4973e5bb0b9c6ff93e903679cfa Mon Sep 17 00:00:00 2001 From: ALICE Action Bot Date: Sun, 23 Aug 2026 08:33:39 +0000 Subject: [PATCH 3/3] Please consider the following formatting changes --- PWGCF/Femto/Macros/cutculator_gui.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 PWGCF/Femto/Macros/cutculator_gui.py diff --git a/PWGCF/Femto/Macros/cutculator_gui.py b/PWGCF/Femto/Macros/cutculator_gui.py old mode 100755 new mode 100644