From bc9bf43cd8407f057cea2365a31b381b85098883 Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 20 Apr 2026 17:29:11 -0700 Subject: [PATCH 01/10] initial formula implementation with new table class, row widgets, and obs dataclasses --- src/badger/archive.py | 10 +- src/badger/formula_utils.py | 167 ++++ src/badger/gui/components/editable_table_2.py | 939 ++++++++++++++++++ src/badger/gui/components/env_cbox.py | 61 +- src/badger/gui/components/routine_page.py | 58 +- src/badger/gui/windows/formula_dialog.py | 371 +++++++ src/badger/routine.py | 26 +- 7 files changed, 1602 insertions(+), 30 deletions(-) create mode 100644 src/badger/formula_utils.py create mode 100644 src/badger/gui/components/editable_table_2.py create mode 100644 src/badger/gui/windows/formula_dialog.py diff --git a/src/badger/archive.py b/src/badger/archive.py index 3f9d242b..3acd6ee3 100644 --- a/src/badger/archive.py +++ b/src/badger/archive.py @@ -2,6 +2,7 @@ import time import warnings import logging +import yaml from badger.utils import ts_float_to_str from badger.settings import init_settings @@ -156,7 +157,14 @@ def load_run(run_fname: str) -> Routine: # TODO: create utility function to catch warnings to remove code # duplication with warnings.catch_warnings(record=True) as caught_warnings: - routine = Routine.from_file(filename) + + with open(filename, "r") as f: + data = yaml.safe_load(f) + routine = Routine(**data) + print(f"yaml.safe_load: {routine.formulas}") + + # routine = Routine.from_file(filename) + print(f"Routine.from_file: {routine.formulas}") # Check if any user warnings were caught for warning in caught_warnings: diff --git a/src/badger/formula_utils.py b/src/badger/formula_utils.py new file mode 100644 index 00000000..cbe7dc12 --- /dev/null +++ b/src/badger/formula_utils.py @@ -0,0 +1,167 @@ +import re +import ast +import math +import statistics +import numpy as np +from typing import Set, Dict, Any, Tuple + +_ALLOWED_FUNC_NAMES: set[str] = { + *vars(math), + *vars(statistics), + *vars(np), +} + +_ALLOWED_NODES = ( + ast.Expression, + ast.Constant, + ast.UnaryOp, + ast.UAdd, + ast.USub, + ast.BinOp, + ast.Add, + ast.Sub, + ast.Mult, + ast.Div, + ast.Pow, + ast.Mod, + ast.Call, + ast.Name, + ast.Load, + ast.BitXor, +) + + +def validate_formula(expr: str, allowed_symbols: Set[str]) -> None: + tree = ast.parse(expr, mode="eval") + + for node in ast.walk(tree): + if not isinstance(node, _ALLOWED_NODES): + raise ValueError(f'Operator "{type(node).__name__}" not allowed') + + if isinstance(node, ast.Call): + fn = node.func.id if isinstance(node.func, ast.Name) else None + if fn not in _ALLOWED_FUNC_NAMES: + raise ValueError(f'Function "{fn}" not permitted') + + if isinstance(node, ast.Name): + if node.id not in allowed_symbols and node.id not in _ALLOWED_FUNC_NAMES: + raise ValueError(f'Unknown symbol "{node.id}"') + + +def sanitize_for_validation(expr: str) -> tuple[str, set[str]]: + """Replace backtick-quoted variables like `PV1` with temp identifiers (v0, v1, ...). + + Returns (python_expr, allowed_syms). `allowed_syms` should be passed to validate_formula. + """ + mapping: dict[str, str] = {} + + def _repl(match: re.Match) -> str: + var = match.group(1) + if var not in mapping: + mapping[var] = f"v{len(mapping)}" + return mapping[var] + + # Match `...` (no backticks inside); preserve everything else unchanged + python_expr = re.sub(r"`([^`]+)`", _repl, expr) + return python_expr, set(mapping.values()) + + +VAR = re.compile(r"`([^`]+)`") # find `var` tokens + + +def expanded_formula_mapping(data: dict) -> Tuple[Dict[str, str], Dict[str, str]]: + """ + Returns: + forward: {name: expanded_formula_string} + reverse: {expanded_formula_string: name} + """ + cache: Dict[str, str] = {} + stack = set() + + formulas = data.get("formulas", {}) or {} + output_names = list(data["vocs"].output_names) + print(f"start expanding formulas: {formulas}") + + def expand_node(node: Dict[str, Any]) -> str: + # print(f"expand node? {node}") + s = node["formula_str"] + mapping = node.get("variable_mapping") or {} + + # print(f"mapping: {mapping}") + def sub(m: re.Match) -> str: + var = m.group(1) + # print("SUB") + # print(f" var: {var}") + if var not in mapping: + raise KeyError(f"Missing mapping for `{var}` in formula: {s!r}") + # print(f" mapping: {mapping}") + target = mapping[var] + # print(f" target: {mapping[var]}") + if target is None: # base variable + return f"`{var}`" + return f"({expand_node(target)})" + + return VAR.sub(sub, s) + + def expand_name(name: str) -> str: + print(f"expand_name: {name}") + if name in cache: + # If it has already been expanded, use the cached version + return cache[name] + if name in stack: + # Don't allow circular formulas! + raise ValueError(f"Cycle detected while expanding {name!r}") + if name not in formulas: + raise KeyError(f"Unknown formula name: {name!r}") + + stack.add(name) + # print(f"formulas: {formulas}") + # print(f"node: {formulas[name]}") + out = expand_node(formulas[name]) + stack.remove(name) + + cache[name] = out + return out + + forward = {} + + for name in formulas: + expanded_name = expand_name(name) + forward[name] = expanded_name + + for output_name in output_names: + if output_name not in formulas: + # it is not a formula, should map to itself + forward[output_name] = output_name + + reverse: Dict[str, str] = {} + for name, expanded in forward.items(): + reverse.setdefault(expanded, name) + + return forward, reverse + + +def stat_key_from_expr(expr: str) -> str: + # Currently unused + # Extract statistic key from an expression like + # "std(`PV1`)/mean(`PV1`)" or "percentile(`PV1`, 90)" + + # use string parsing to find what the stat function is + s = re.sub(r"\s+", "", expr) + + ident = r"`[^`]+`" # r"`[^`]*`" # ANY content inside backticks (including empty) + # If you want at least 1 char: ident = r"`[^`]+`" + + if re.fullmatch(rf"std\({ident}\)/mean\({ident}\)", s): + return "std_rel" + if re.fullmatch(rf"mean\({ident}\)", s): + return "mean" + if re.fullmatch(rf"std\({ident}\)", s): + return "std" + + m = re.fullmatch(rf"percentile\({ident},(\d+)\)", s) + if m: + p = int(m.group(1)) + return "median" if p == 50 else f"p{p}" + + raise ValueError(f"Unrecognized expression: {expr!r}") diff --git a/src/badger/gui/components/editable_table_2.py b/src/badger/gui/components/editable_table_2.py new file mode 100644 index 00000000..80df00ff --- /dev/null +++ b/src/badger/gui/components/editable_table_2.py @@ -0,0 +1,939 @@ +from dataclasses import dataclass, field +from PyQt5.QtWidgets import ( + QWidget, + QHBoxLayout, + QVBoxLayout, + QScrollArea, + QCheckBox, + QLabel, + QComboBox, + QSizePolicy, + QFrame, + QLineEdit, + QMessageBox, +) +from PyQt5.QtCore import pyqtSignal +from typing import Any, Optional, Tuple +import re + +import logging + +logger = logging.getLogger(__name__) + + +class FormulaNameLabel(QLabel): + """Custom QLineEdit that emits a signal on double-click.""" + + double_clicked = pyqtSignal() + + def mouseDoubleClickEvent(self, event): + """Override to emit signal on double-click.""" + self.double_clicked.emit() + super().mouseDoubleClickEvent(event) + + +@dataclass +class ObservableItem: + checked: bool + name: str + is_name_editable: bool = False + is_formula_editable: bool = False + formula: dict[str, Any] = field( + default_factory=lambda: {"formula_str": None, "variable_mapping": {}} + ) + + +@dataclass +class ObjectiveItem(ObservableItem): + """ + Dataclass to represent an objective row. + + """ + + rule: str = "MINIMIZE" + + +@dataclass +class ConstraintItem(ObservableItem): + """Dataclass to represent a constraint row.""" + + relation: str = "<" + threshold: float = 0.0 + critical: bool = False + + +# Matches any of: +# mean(`x`), std(`x`), percentile(`x`,80/75/50/25), std(`x`)/mean(`x`) +_PATTERNS = [ + ("mean", re.compile(r"^\s*mean\s*\(\s*`(?P[^`]+)`\s*\)\s*$", re.I)), + ("std", re.compile(r"^\s*std\s*\(\s*`(?P[^`]+)`\s*\)\s*$", re.I)), + ( + "p80", + re.compile(r"^\s*percentile\s*\(\s*`(?P[^`]+)`\s*,\s*80\s*\)\s*$", re.I), + ), + ( + "p75", + re.compile(r"^\s*percentile\s*\(\s*`(?P[^`]+)`\s*,\s*75\s*\)\s*$", re.I), + ), + ( + "p25", + re.compile(r"^\s*percentile\s*\(\s*`(?P[^`]+)`\s*,\s*25\s*\)\s*$", re.I), + ), + ( + "median", + re.compile(r"^\s*percentile\s*\(\s*`(?P[^`]+)`\s*,\s*50\s*\)\s*$", re.I), + ), + ( + "std_rel", + re.compile( + r"^\s*std\s*\(\s*`(?P[^`]+)`\s*\)\s*/\s*mean\s*\(\s*`(?P=var)`\s*\)\s*$", + re.I, + ), + ), +] + + +class ObjectiveRowWidget(QWidget): + """A custom widget representing a single objective row with checkbox, name, and rule combobox.""" + + item_renamed = pyqtSignal(str, str) # Emits (old_name, new_name) + formula_updated = pyqtSignal(str, ObservableItem) # Emits (new_formula_str) + formula_double_clicked = pyqtSignal( + QWidget + ) # Emitted when formula name is double-clicked + + def __init__(self, objective_item: ObjectiveItem, row_index: int, parent=None): + super().__init__(parent) + self.item = objective_item + # print(f"Creating ObjectiveRowWidget for item: {self.item.name}, stat: {self.item.stat}") + self.row_index = row_index + self._init_ui() + self._connect_signals() + self._apply_style() + + def _init_ui(self): + """Initialize the UI components.""" + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 1, 1, 4) + layout.setSpacing(1) + self.setStyleSheet(""" + border-radius: 0px; + """) + + # Checkbox + self.checkbox = QCheckBox() + self.checkbox.setChecked(self.item.checked) + self.checkbox.setFixedWidth(20) + layout.addWidget(self.checkbox) + + # Name input field + self.name_input = FormulaNameLabel(self.item.name) + self.name_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + # Disable editing if this is not a formula item + if not self.item.formula["formula_str"]: + # self.name_input.setReadOnly(True) + self.name_input.setStyleSheet(""" + border-radius: 0px; + border: 1px solid transparent; + padding: 0px; + """) + else: + self.name_input.setStyleSheet(""" + border: 1px solid transparent; + border-radius: 0px; + """) + layout.addWidget(self.name_input) + + # Rule combobox + self.rule_combo = QComboBox() + self.rule_combo.addItems(["MINIMIZE", "MAXIMIZE"]) + self.rule_combo.setCurrentText(self.item.rule) + self.rule_combo.setFixedWidth(120) + layout.addWidget(self.rule_combo) + + # !-- + # statistic combobox + self.stat_combo = QComboBox() + self.stat_combo.addItems( + [ + "none", + "mean", + "std", + "std_rel", + "p80", + "p75", + "median", + "p25", + ] + ) + # print(f"ObjectiveRowWidget stat: {self.item.stat}") + # self.stat_combo.setCurrentText(self.item.stat) + self.stat_combo.setFixedWidth(120) + # layout.addWidget(self.stat_combo) + # --! + + def _apply_style(self): + """Apply alternating row colors.""" + if self.row_index % 2 == 0: + self.setStyleSheet("alternate-background-color: #262E38;") + else: + self.setStyleSheet("background-color: #262E38;") + + if self.item.formula["formula_str"]: + # need to distinguish between formulas and new non-formula vars, or have a separate flag + # for new variables? + # border: 1px solid #356792; + self.name_input.setStyleSheet(""" + + + QLabel { + color: DarkCyan; + border: 1px solid transparent; + } + QLabel:hover { + + color: LightSeaGreen; + } + + """) + self._update_tooltip() + else: + # Read-only items should not respond to hover or clicks + self.name_input.setStyleSheet(""" + border-radius: 0px; + border: 1px solid transparent; + padding: 0px; + """) + + if self.item.formula["variable_mapping"]: + self.stat_combo.setEnabled( + False + ) # disable stat selection for formula items + # self.name_input.setStyleSheet("color: LightSeaGreen;") + + def _update_tooltip(self): + """Update the tooltip to display the formula_str.""" + if self.item.formula["formula_str"]: + self.name_input.setToolTip(f"Formula: {self.item.formula['formula_str']}") + else: + self.name_input.setToolTip("") + + def update_formula_tooltip(self): + """Public method to update the tooltip when formulas change.""" + self._update_tooltip() + + def _connect_signals(self): + """Connect UI signals to data updates.""" + self.checkbox.stateChanged.connect(self._on_checkbox_changed) + self.rule_combo.currentTextChanged.connect(self._on_rule_changed) + self.stat_combo.currentTextChanged.connect(self._on_stat_changed) + # self.name_input.returnPressed.connect(self._on_name_changed) + # self.name_input.editingFinished.connect(self._on_name_changed) # on focus loss + # Only connect double-click signal if this is a formula item + if self.item.formula["formula_str"]: + self.name_input.double_clicked.connect( + lambda: self.formula_double_clicked.emit(self) + ) + + def _on_checkbox_changed(self): + """Update item when checkbox state changes.""" + self.item.checked = self.checkbox.isChecked() + + def _on_rule_changed(self): + """Update item when rule selection changes.""" + self.item.rule = self.rule_combo.currentText() + + def _on_stat_changed(self): + """Update item when statistic selection changes.""" + # self.item.formula["stat"] = self.stat_combo.currentText() + self.item.stat = self.stat_combo.currentText() + print( + f" select stat: {self.item.stat} for {self.item.name} NOT IMPLEMENTED YET" + ) + + # removing implementation for now because I need to figure more things out, can revisit + + # new_function = None + + # if not self.item.formula["formula_str"]: + # new_function = self._construct_obs_func_str(self.item.stat, self.item.name) + # elif "`" in self.item.formula["formula_str"]: + # try: + # stat_key, var_name = self._parse_stat_formula(self.item.formula["formula_str"]) + # new_function = self._construct_obs_func_str(stat_key, var_name) + # except TypeError: + # print(f"Failed to parse formula_str: {self.item.formula['formula_str']}") + # new_function = None + # return + # else: + # new_function = self._construct_obs_func_str(self.item.stat, self.item.formula["formula_str"]) + # print(f"Constructed new function from formula_str: {new_function}") + + # print(f"Stat changed for {self.item.name}, new function: {new_function}") + # NOTHING HAPPENS yes because I can't figure out how to make it work + # self.item.formula["formula_str"] = new_function + # self.item.formula["variable_mapping"] = {self.item.name: None} if new_function else {} + # if new_function: + # self.formula_updated.emit(new_function, self.item) + + def _construct_obs_func_str(self, operation: str, obj_name: str): + print( + f"Constructing observable function string for operation: {operation}, object: {obj_name}" + ) + stats_mapping = { + "mean": lambda x: f"mean(`{x}`)", + "std": lambda x: f"std(`{x}`)", + "p80": lambda x: f"percentile(`{x}`,80)", + "p75": lambda x: f"percentile(`{x}`,75)", + "p25": lambda x: f"percentile(`{x}`,25)", + "median": lambda x: f"percentile(`{x}`,50)", + "std_rel": lambda x: f"std(`{x}`)/mean(`{x}`)", + } + + if operation in stats_mapping: + new_obj_name = stats_mapping[operation](obj_name) + print(f"Constructed new observable function string: {new_obj_name}") + return new_obj_name + # pass + + def _parse_stat_formula(self, expr: str) -> Optional[Tuple[str, str]]: + """ + Returns (stat_key, variable_name) if expr matches one of the supported formulas, + else None. + """ + for key, rx in _PATTERNS: + m = rx.match(expr) + if m: + return key, m.group("var") + return None + + def update_item_name(self, new_name: str): + """ + Update the name of the item. This is called to update the + name externally (e.g. from FormulaEditor rather than name_input QLineEdit). + emits item_renamed signal so that the parent can update references. + """ + old_name = self.item.name + self.item.name = new_name + self.name_input.setText(new_name) + self.item_renamed.emit(old_name, new_name) + + def _on_name_changed(self): + """ + Update item when name text changes. This is called from the + item_name QLineEdit. Updates the item name with the current text + and emits item_renamed signal so that the parent can update references. + """ + old_name = self.item.name + new_name = self.name_input.text() + + if old_name != new_name: + self.item.name = new_name + self.item_renamed.emit(old_name, new_name) + + +class ObjectiveInsertRowWidget(QWidget): + """A custom widget for inserting new objectives with a QLineEdit for the name.""" + + item_requested = pyqtSignal(str) # Emits the name when user presses Enter + + def __init__(self, parent=None): + super().__init__(parent) + self._init_ui() + self._connect_signals() + + def _init_ui(self): + """Initialize the UI components.""" + layout = QHBoxLayout(self) + layout.setContentsMargins(1, 1, 1, 1) + layout.setSpacing(0) + + # Checkbox (disabled, just for alignment) + checkbox_spacer = QLabel("") + # self.checkbox.setEnabled(False) + checkbox_spacer.setFixedWidth(20) + checkbox_spacer.setStyleSheet(""" + border-radius: 0px; + """) + layout.addWidget(checkbox_spacer) + + # Name input field + self.name_input = QLineEdit() + self.name_input.setPlaceholderText("Enter new objective name...") + self.name_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + self.name_input.setStyleSheet(""" + border-radius: 0px; + """) + layout.addWidget(self.name_input) + + # Spacer to align with rule column + spacer = QLabel("") + spacer.setFixedWidth(120) + spacer.setStyleSheet(""" + border-radius: 0px; + """) + layout.addWidget(spacer) + + # Style + self.setStyleSheet(""" + background-color: #303A45; + border-top: 1px solid #455364; + """) + + def _connect_signals(self): + """Connect signals for inserting new items.""" + self.name_input.returnPressed.connect(self._on_return_pressed) + + def _on_return_pressed(self): + """Handle Enter key press.""" + name = self.name_input.text().strip() + if name: + self.item_requested.emit(name) + self.name_input.clear() + self.name_input.setFocus() + + +class HeaderWidget(QWidget): + """A custom widget representing the table header.""" + + def __init__(self, parent=None, additional_columns: list[str] = []): + super().__init__(parent) + self._init_ui(additional_columns) + + def _init_ui(self, additional_columns: list[str]) -> None: + """Initialize the header UI.""" + layout = QHBoxLayout(self) + layout.setContentsMargins(1, 1, 1, 4) + layout.setSpacing(1) + + # Checkbox column header (empty or with a master checkbox) + checkbox_header = QLabel("") + checkbox_header.setFixedWidth(20) + layout.addWidget(checkbox_header) + + # Name column header + name_header = QLabel("Name") + name_header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + # name_header.setStyleSheet("font-weight: bold;") + layout.addWidget(name_header) + + # Add additional column headers + for column_name in additional_columns: + col_header = QLabel(column_name) + col_header.setFixedWidth(120) + layout.addWidget(col_header) + + """# Rule column header + rule_header = QLabel("Rule") + rule_header.setFixedWidth(120) + # rule_header.setStyleSheet("font-weight: bold;") + layout.addWidget(rule_header) + + # Stat column header + stat_header = QLabel("Statistic") + stat_header.setFixedWidth(120) + # rule_header.setStyleSheet("font-weight: bold;") + layout.addWidget(stat_header)""" + + # Style the header + self.setStyleSheet(""" + background-color: #455364; + border-bottom: 1px solid #a0a0a0; + border-radius: 0px; + """) + self.setFixedHeight(30) + + +class ObjectivesListView(QScrollArea): + """A scrollable list view for displaying objectives as row widgets with filtering support.""" + + data_changed = pyqtSignal() # Signal to indicate that data has changed + formula_double_clicked = pyqtSignal(ObjectiveRowWidget) + + def __init__(self, parent=None): + super().__init__(parent) + self.setWidgetResizable(True) + self.setFrameShape(QFrame.StyledPanel) + self.setMinimumHeight(200) + + # Main container widget + main_container = QWidget() + main_layout = QVBoxLayout(main_container) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Add header + self.header = HeaderWidget(additional_columns=["Rule"]) # , "Statistic"]) + main_layout.addWidget(self.header) + + # Container widget to hold all row widgets + self.container = QWidget() + self.container_layout = QVBoxLayout(self.container) + self.container_layout.setContentsMargins(0, 0, 0, 0) + self.container_layout.setSpacing(0) + + main_layout.addWidget(self.container) + + self.setWidget(main_container) + + # Apply table-like styling + self.setStyleSheet(""" + QScrollArea { + border: 1px solid #455364; + } + """) + + # Store all items + self._all_items: list[ObjectiveItem] = [] + + # Store currently displayed row widgets + self.row_widgets: list[ObjectiveRowWidget] = [] + + # Filter state + self._filter_text = "" + self._show_checked_only = False + + # Create insert row widget (will be added to layout in _rebuild_view) + self.insert_row = ObjectiveInsertRowWidget(parent=self) + self.insert_row.item_requested.connect(self.add_item) + self.insert_row.hide() + + def update_items( + self, + objectives: list[dict[str, Any]], + status: dict[str, bool], + formulas: dict[str, dict[str, Any]] | None = None, + vocs_signal: bool = False, + ) -> None: + """Update the list with objectives data. + + Parameters + ---------- + objectives : dict + Dictionary with form {name: [rule]} + status : dict + Status information for each objective + vocs_signal : bool + Whether to emit a signal (not used currently) + """ + # Clear all items + self._all_items.clear() + print("UPDATING OBJECTIVES LIST VIEW") + + # Create new items from objectives + for objective in objectives: + print(f"... objective: {objective}") + for name, rule_list in objective.items(): + print(f"... name: {name}, rule_list: {rule_list}") + rule = rule_list[0] if rule_list else "MINIMIZE" + # stat = rule_list[1] if len(rule_list) > 1 else "none" + item = ObjectiveItem( + checked=status.get(name, False), + name=name, + rule=rule, + ) + self._all_items.append(item) + + # Update formulas if provided + if formulas is not None: + for name in formulas: + if name in self.item_names: + self.items[name].formula = formulas[name] + """# check for formula_str and variable_mapping to determine if item should be editable + if item.formula["formula_str"] and item.formula["variable_mapping"]: + # This is a complete formula with variable mapping + item.is_name_editable = True + item.is_formula_editable = True + elif item.formula["formula_str"]: + # This is a formula without variable mapping, + # the formula_str is the same as the name + item.is_name_editable = True + item.is_formula_editable = False""" + + else: + # If formula name is not in items, add it as a new item + print("HMM I DON'T THINK THIS SHOULD PRINT") + + """formula_item = FormulaItem(name=name) + formula_item.selected = False # start unselected by default + formula_item.info = self.default_info() + formula_item.formula = { + "formula_str": name, + "variable_mapping": name, + } + self.formulaItems[name] = formula_item""" + + # Rebuild view with current filters + self._rebuild_view() + + if vocs_signal: + self.update_vocs() + + def _rebuild_view(self) -> None: + """Rebuild the visible row widgets based on current filters.""" + print("Rebuilding objectives list view...") + # Clear existing displayed widgets + for widget in self.row_widgets: + widget.item_renamed.disconnect() + widget.formula_double_clicked.disconnect() + widget.deleteLater() + self.row_widgets.clear() + + # Remove old layout items, skip filtering for + while self.container_layout.count() > 0: + item = self.container_layout.takeAt(self.container_layout.count() - 1) + if item.spacerItem(): + pass + elif item.widget() == self.insert_row: + pass + + # Filter and display items + row_index = 0 + for item in self._all_items: + if self._passes_filters(item): + row_widget = ObjectiveRowWidget(item, row_index, parent=self) + row_widget.item_renamed.connect(self._on_item_renamed) + row_widget.formula_updated.connect( + lambda formula_str, i=item: self.update_item_formula(i, formula_str) + ) + row_widget.formula_double_clicked.connect( + self.formula_double_clicked.emit + ) + self.row_widgets.append(row_widget) + self.container_layout.addWidget(row_widget) + row_index += 1 + + # Show and add insert row + self.insert_row.show() + self.container_layout.addWidget(self.insert_row) + + # Add stretch at the end to push items to top + self.container_layout.addStretch() + + def _passes_filters(self, item: ObjectiveItem) -> bool: + """Check if an item passes all active filters.""" + # Filter by text (case-insensitive substring match) + if self._filter_text: + if self._filter_text.lower() not in item.name.lower(): + return False + + # Filter by checked status + if self._show_checked_only and not item.checked: + return False + + return True + + def set_filter(self, text: str) -> None: + """Filter items by name substring (case-insensitive). + + Parameters + ---------- + text : str + Substring to filter by. Empty string clears the filter. + """ + self._filter_text = text + self._rebuild_view() + + def show_checked_only(self, enabled: bool) -> None: + """Show only checked items. + + Parameters + ---------- + enabled : bool + If True, only checked items will be displayed. + """ + self._show_checked_only = enabled + self._rebuild_view() + + def get_items(self) -> list[ObjectiveItem]: + """Get the unfiltered list of all ObjectiveItem objects. + + Returns + ------- + list[ObjectiveItem] + All items, regardless of current filter state. + """ + return self._all_items + + def get_selected_items(self) -> list[ObjectiveItem]: + """Get the list of currently selected (checked) items. + + Returns + ------- + list[ObjectiveItem] + List of checked items. + """ + return [item for item in self._all_items if item.checked] + + def export_data(self): + selected_items = self.get_selected_items() + print( + f"Exporting data for selected items: {[item.name for item in selected_items]}" + ) + return [{item.name: {"rule": item.rule}} for item in selected_items] + + @property + def item_names(self) -> list[str]: + """Get a list of all item names.""" + return [item.name for item in self._all_items] + + @property + def items(self) -> dict[str, ObjectiveItem]: + """Get a dictionary of items keyed by name.""" + return {item.name: item for item in self._all_items} + + @property + def formulas(self) -> dict: + _formulas = {} + for item in self._all_items: + if item.formula[ + "formula_str" + ]: # "] != "none": # second part saves stat selection + _formulas[item.name] = item.formula + + return _formulas + + @property + def formula_strs(self) -> list[str]: + return [ + item.formula["formula_str"] + for item in self._all_items + if item.formula["formula_str"] + ] + + def show_duplicate_warning(self, name: str) -> None: + QMessageBox.warning( + self, + "Item already exists!", + f"Item {name} already exists!", + ) + + def add_item( + self, name: str, formula_str: str = None, checked: bool = False + ) -> None: + """Add a new objective item to the list. + + Parameters + ---------- + name : str + The name of the new objective. + """ + if name in self.item_names: + # If an item with the same name already exists, show a warning and do not add + self.show_duplicate_warning(name) + return + + # check for duplicate formulas + if name in self.formula_strs: + self.show_duplicate_warning(name) + return + + # check for duplicate formulas + if formula_str in self.formula_strs: + self.show_duplicate_warning(formula_str) + return + + print(f"Adding new item: {name}, formula: {formula_str}") + + # Create new objective item with default rule and formula + new_item = ObjectiveItem( + checked=checked, + name=name, + rule="MINIMIZE", + ) + + if formula_str: + # add item as formula + new_item.formula["formula_str"] = formula_str + new_item.formula["variable_mapping"] = self.get_variable_mapping( + formula_str, + current_name=name, + ) + else: + new_item.formula["formula_str"] = name + if "`" in name: + new_item.formula["variable_mapping"] = self.get_variable_mapping( + name, current_name=name + ) + + # Add to items list + self._all_items.append(new_item) + + # Rebuild view to display new item + self._rebuild_view() + + # self.update_vocs() + + def get_variable_mapping( + self, formula_str: str, current_name: str + ) -> dict[str, str]: + + matches = self.check_for_var_references(formula_str) # find variable references + # matches is a list of variable name strings + + visited = set() + + variable_mapping = {} + for match in matches: + if match in visited: + variable_mapping[match] = None + continue + + item = self.items[match] + + if item.formula["formula_str"]: + # If item is formula, get full formula with variable mapping for later expansion + variable_mapping[item.name] = item.formula + else: + # If item is not formula, map to var name + variable_mapping[item.name] = item.formula["formula_str"] + print(f"match: {match}, var_map: {variable_mapping}") + + return variable_mapping + + def check_for_var_references(self, expr: str) -> list[str]: + if not self.item_names: + return [] + pat = re.compile( + rf"(? None: + """Handle renaming of an item and update references in other items. + + Parameters + ---------- + old_name : str + The previous name of the item + new_name : str + The new name of the item + """ + # Update formula_str in all items that reference the old name + for item in self._all_items: + if item.formula["formula_str"]: + # Only match variable names within backticks + old_pattern = rf"`{re.escape(old_name)}`" + item.formula["formula_str"] = re.sub( + old_pattern, f"`{new_name}`", item.formula["formula_str"] + ) + + # Update variable_mapping keys + if old_name in item.formula["variable_mapping"]: + item.formula["variable_mapping"][new_name] = item.formula[ + "variable_mapping" + ].pop(old_name) + + # Update tooltips for all visible row widgets + for row_widget in self.row_widgets: + row_widget.update_formula_tooltip() + + def update_item_formula(self, item: ObjectiveItem, new_formula_str: str) -> None: + """Handle updating an item's formula and recalculate its variable mapping. + + Parameters + ---------- + item : ObjectiveItem + The item whose formula is being updated + new_formula_str : str + The new formula string + + + """ + + self.check_for_circular_reference(item, new_formula_str) + + # check for duplicate formulas + if new_formula_str in self.formula_strs: + self.show_duplicate_warning(new_formula_str) + return + + item.formula["formula_str"] = new_formula_str + # Recalculate variable mapping for the updated formula + item.formula["variable_mapping"] = self.get_variable_mapping( + new_formula_str, current_name=item.name + ) + + # update variable_mapping for any items that reference this item as a variable + for other_item in self._all_items: + if other_item.formula["formula_str"]: + # print(f"other item: {other_item.name}, formula: {other_item.formula}") + # replace old variable mapping with new formula for this item + if item.name in other_item.formula["variable_mapping"]: + other_item.formula["variable_mapping"][item.name] = item.formula + + # Update tooltips for all visible row widgets + for row_widget in self.row_widgets: + row_widget.update_formula_tooltip() + + def _on_item_formula_updated( + self, item: ObjectiveItem, new_formula_str: str + ) -> None: + """ + Update formula BEFORE checking for circular references. This allows + us to support self-referential formulas, where an item can reference itself as long as the circular reference is through the variable mapping and not the formula_str. For example, if we have an item A with formula_str "mean(`A`)", this is a valid self-referential formula because the variable mapping for A will be empty (since it doesn't directly reference any other items), and when we check for circular references, we will see that A does not reference itself through the variable mapping. However, if we had an item B with formula_str "mean(`A`)" and then updated A's formula_str to "mean(`B`)", this would create a circular reference because A's variable mapping would include B, and B's variable mapping would include A. + + """ + # check for duplicate formulas + if new_formula_str in self.formula_strs: + self.show_duplicate_warning(new_formula_str) + return + print(f"Updating formula for item {item.name}, new formula: {new_formula_str}") + item.formula["formula_str"] = new_formula_str + # Recalculate variable mapping for the updated formula + item.formula["variable_mapping"] = self.get_variable_mapping( + new_formula_str, current_name=item.name + ) + print(item.formula["variable_mapping"]) + + # update variable_mapping for any items that reference this item as a variable + for other_item in self._all_items: + if other_item.formula["formula_str"]: + # print(f"other item: {other_item.name}, formula: {other_item.formula}") + # replace old variable mapping with new formula for this item + if item.name in other_item.formula["variable_mapping"]: + other_item.formula["variable_mapping"][item.name] = item.formula + + # Update tooltips for all visible row widgets + for row_widget in self.row_widgets: + row_widget.update_formula_tooltip() + + """def check_for_circular_reference(self, item: ObjectiveItem, new_formula_str: str) -> None: + var_map = self.get_variable_mapping(new_formula_str, current_name=item.name) + + def find_refs(var_mapping: dict, depth: int = 0): + for name, mapping in var_mapping.items(): + # Allow A referencing A directly in its own formula (depth == 0), + # but disallow A being reached through another dependency (depth > 0). + if name == item.name and depth > 0: + raise ValueError("Circular reference detected!") + + if isinstance(mapping, dict): + find_refs(mapping, depth + 1) + + find_refs(var_map, 0)""" + + def check_for_circular_reference( + self, item: ObjectiveItem, new_formula_str: str + ) -> bool: + # Check for circular references + print("check for circular references") + print(self.get_variable_mapping(new_formula_str, item.name)) + + def find_refs(var_mapping: dict): + for name, mapping in var_mapping.items(): + print(name, mapping) + if name == item.name: + raise ValueError("Circular reference detected!") + if isinstance(mapping, dict): + find_refs(mapping) + + find_refs(self.get_variable_mapping(new_formula_str, item.name)) + + def update_vocs(self): + logging.debug("Emitting data_changed signal from editable_table") + self.data_changed.emit() diff --git a/src/badger/gui/components/env_cbox.py b/src/badger/gui/components/env_cbox.py index 944f272d..b7805287 100644 --- a/src/badger/gui/components/env_cbox.py +++ b/src/badger/gui/components/env_cbox.py @@ -26,6 +26,7 @@ from badger.gui.components.con_table import ConstraintTable from badger.gui.components.obs_table import ObservableTable from badger.gui.components.data_table import init_data_table +from badger.gui.windows.formula_dialog import BadgerFormulaDialog, FormulaEdit from badger.settings import init_settings from badger.gui.utils import ( MouseWheelWidgetAdjustmentGuard, @@ -34,6 +35,10 @@ from badger.utils import strtobool from xopt.vocs import VOCS, ConstraintEnum import logging +from badger.gui.components.editable_table_2 import ( + ObjectivesListView, + ObjectiveRowWidget, +) LABEL_WIDTH = 96 ENV_PARAMS_BTN = 1 # use button or collapsible box for env parameters @@ -409,14 +414,22 @@ def init_ui(self): self.edit_obj = edit_obj = QLineEdit() edit_obj.setPlaceholderText("Filter objectives...") edit_obj.setFixedWidth(192) + self.add_formula_obj = add_formula_obj = QPushButton("Add Formula") + self.check_only_obj = check_only_obj = QCheckBox("Show Checked Only") check_only_obj.setChecked(False) hbox_action_obj.addWidget(edit_obj) hbox_action_obj.addStretch() + hbox_action_obj.addWidget(add_formula_obj) hbox_action_obj.addWidget(check_only_obj) self.obj_table = ObjectiveTable() - vbox_obj_edit.addWidget(self.obj_table) + # vbox_obj_edit.addWidget(self.obj_table) + + # New objectives list view with row widgets + self.objectives_list_view = ObjectivesListView() + vbox_obj_edit.addWidget(self.objectives_list_view) + hbox_obj.addWidget(edit_obj_col) cbox_more = CollapsibleBox(self, " More") @@ -503,6 +516,7 @@ def config_logic(self): self.edit_var.textChanged.connect(self.filter_var) self.check_only_var.stateChanged.connect(self.toggle_var_show_mode) self.edit_obj.textChanged.connect(self.filter_obj) + self.add_formula_obj.clicked.connect(self.add_formula) self.check_only_obj.stateChanged.connect(self.toggle_obj_show_mode) self.edit_con.textChanged.connect(self.filter_con) self.check_only_con.stateChanged.connect(self.toggle_con_show_mode) @@ -516,6 +530,11 @@ def config_logic(self): self.sta_table.data_changed.connect(lambda: self.update_vocs("sta_table")) self.var_table.data_changed.connect(lambda: self.update_vocs("var_table")) + self.objectives_list_view.data_changed.connect( + lambda: self.update_vocs("objectives_list_view") + ) + self.objectives_list_view.formula_double_clicked.connect(self.edit_formula) + def update_vocs(self, origin: str): logger.debug(f"Emitting vocs_updated signal from env_cbox: {origin}") vocs, _ = self.compose_vocs() @@ -559,9 +578,13 @@ def filter_var(self): def toggle_obj_show_mode(self, _): self.obj_table.update_show_selected_only(self.check_only_obj.isChecked()) + self.objectives_list_view.show_checked_only( + self.check_only_obj.isChecked() + ) # new table def filter_obj(self): self.obj_table.update_keyword(self.edit_obj.text()) + self.objectives_list_view.set_filter(self.edit_obj.text()) # new table def toggle_con_show_mode(self, _): self.con_table.update_show_selected_only(self.check_only_con.isChecked()) @@ -607,15 +630,42 @@ def update_stylesheets(self, environment=""): stylesheet = "" self.setStyleSheet(stylesheet) + def add_formula(self): + print("add formula button pressed") + dlg = BadgerFormulaDialog( + parent=self, + table=self.objectives_list_view, + ) + self.tc_dialog = dlg + try: + dlg.exec() + finally: + self.tc_dialog = None + + def edit_formula(self, row_widget: ObjectiveRowWidget): + print("edit formula:") + dlg = FormulaEdit( + parent=self, + table=self.objectives_list_view, + row_widget=row_widget, + ) + self.tc_dialog = dlg + try: + dlg.exec() + finally: + self.tc_dialog = None + def compose_vocs(self) -> tuple[VOCS, list[str]]: # Compose the VOCS settings variables = self.var_table.export_variables() objectives: dict[str, Any] = {} - for objective in self.obj_table.export_data(): + for objective in self.objectives_list_view.export_data(): obj_name = next(iter(objective)) - (rule,) = objective[obj_name] - objectives[obj_name] = rule + + rule = objective[obj_name]["rule"] + + objectives[obj_name] = rule # [0] constraints: dict[str, list[float | ConstraintEnum]] = {} critical_constraints: list[str] = [] @@ -639,6 +689,9 @@ def compose_vocs(self) -> tuple[VOCS, list[str]]: constants={}, observables=observables, ) + print( + f"VOCS composed in env_cbox: variables={list(vocs.variables.keys())}, objectives={list(vocs.objectives.keys())}, constraints={list(vocs.constraints.keys())}, observables={vocs.observables}" + ) except ValidationError as e: raise BadgerRoutineError( f"\n\nVOCS validation failed: {format_validation_error(e)}" diff --git a/src/badger/gui/components/routine_page.py b/src/badger/gui/components/routine_page.py index 07e94bfe..635b665e 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -54,6 +54,8 @@ ) from badger.factory import list_generators, list_env, get_env from badger.routine import Routine +from badger.formula import extract_variable_keys +from badger.formula_utils import stat_key_from_expr from badger.settings import init_settings from datetime import datetime from badger.utils import ( @@ -485,7 +487,7 @@ def set_options_from_template(self, template_dict: dict[str, Any]): status = {} objectives_names_full = self.configs["observations"] + list(formulas.keys()) for name in objectives_names_full: - obj = {name: ["MINIMIZE"]} + obj = {name: self.env_box.obj_table.default_info()} status[name] = False # selected objectives.append(obj) for name, val in vocs.objectives.items(): @@ -506,7 +508,7 @@ def set_options_from_template(self, template_dict: dict[str, Any]): self.env_box.check_only_obj.blockSignals(False) self.env_box.obj_table.show_selected_only = True - self.env_box.obj_table.update_items(objectives, status, formulas) + # self.env_box.obj_table.update_items(objectives, status, formulas) # set constraints # Initialize the constraints table with env observables @@ -518,7 +520,7 @@ def set_options_from_template(self, template_dict: dict[str, Any]): status = {} constraints_names_full = self.configs["observations"] + list(formulas.keys()) for name in constraints_names_full: - cons = {name: ["<", 0.0, False]} + cons = {name: self.env_box.con_table.default_info()} status[name] = False # selected constraints.append(cons) for name, val in vocs.constraints.items(): @@ -834,13 +836,18 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): objectives = [] status = {} objectives_names_full = self.configs["observations"] + list(formulas.keys()) + # objectives_names_full = list(set(self.configs["observations"]) | set(routine.vocs.objectives.keys()) | set(formulas.keys()) ) + # adding routine.vocs.objectives.keys() allows for new observables to be defined in the routine which are not in the env + print(f"refresh_ui: full_objectives: {objectives_names_full}") for name in objectives_names_full: - obj = {name: ["MINIMIZE"]} + # if name in + obj = {name: self.env_box.obj_table.default_info()} + print(f"obj: default: {obj}") status[name] = False # selected objectives.append(obj) for name, val in routine.vocs.objectives.items(): rule = val - + print(f"refresh_ui: vocs objectives: {name}") idx = objectives_names_full.index(name) if idx == -1: raise BadgerRoutineError( @@ -851,16 +858,16 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): status[name] = True # Show selected objectives only - self.env_box.check_only_obj.blockSignals(True) - self.env_box.check_only_obj.setChecked(True) - self.env_box.check_only_obj.blockSignals(False) self.env_box.edit_obj.blockSignals(True) self.env_box.edit_obj.setText("") self.env_box.edit_obj.blockSignals(False) self.env_box.obj_table.keyword = "" - self.env_box.obj_table.show_selected_only = True + # self.env_box.obj_table.show_selected_only = True + print(f"refresh_ui: update items: {objectives}") with BlockSignalsContext(self.env_box.obj_table): self.env_box.obj_table.update_items(objectives, status, formulas) + with BlockSignalsContext(self.env_box.objectives_list_view): + self.env_box.objectives_list_view.update_items(objectives, status, formulas) # Initialize the constraints table with env observables try: @@ -871,7 +878,7 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): status = {} constraints_names_full = self.configs["observations"] + list(formulas.keys()) for name in constraints_names_full: - cons = {name: ["<", 0.0, False]} + cons = {name: self.env_box.con_table.default_info()} status[name] = False # selected constraints.append(cons) for name, val in routine.vocs.constraints.items(): @@ -916,7 +923,7 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): var_names + self.configs["observations"] + list(formulas.keys()) ) for name in observables_names_full: - obs = {name: []} + obs = {name: {}} status[name] = False # selected observables.append(obs) for name in routine.vocs.observables: @@ -1133,23 +1140,28 @@ def select_env(self, i: int): objectives = [] status = {} for name in self.configs["observations"]: - cons = {name: ["MINIMIZE"]} + # this is where the default value is specified. It should check if this is defined in the environment!!!!! + # also why is this called cons here? + default_obj = self.env_box.obj_table.default_info() + obj = {name: default_obj} status[name] = False # selected - objectives.append(cons) + objectives.append(obj) self.env_box.check_only_obj.blockSignals(True) self.env_box.check_only_obj.setChecked(False) self.env_box.check_only_obj.blockSignals(False) self.env_box.obj_table.show_selected_only = False # with BlockSignalsContext(self.env_box.obj_table): - self.env_box.obj_table.update_items( - objectives, status, formulas={}, vocs_signal=False + self.env_box.obj_table.update_items(objectives, status, vocs_signal=False) + + self.env_box.objectives_list_view.update_items( + objectives, status, vocs_signal=False ) # Initialize the constraints table with env observables constraints = [] status = {} for name in self.configs["observations"]: - cons = {name: ["<", 0.0, False]} + cons = {name: self.env_box.con_table.default_info()} status[name] = False # selected constraints.append(cons) self.env_box.check_only_con.blockSignals(True) @@ -1157,9 +1169,7 @@ def select_env(self, i: int): self.env_box.check_only_con.blockSignals(False) self.env_box.con_table.show_selected_only = False # with BlockSignalsContext(self.env_box.con_table): - self.env_box.con_table.update_items( - constraints, status, formulas={}, vocs_signal=False - ) + self.env_box.con_table.update_items(constraints, status, vocs_signal=False) # Initialize the observable table with env variables and observables observables = [] @@ -1170,7 +1180,7 @@ def select_env(self, i: int): else: var_names = [] for name in var_names + self.configs["observations"]: - obs = {name: []} + obs = {name: {}} status[name] = False # selected observables.append(obs) self.env_box.check_only_sta.blockSignals(True) @@ -1178,9 +1188,7 @@ def select_env(self, i: int): self.env_box.check_only_sta.blockSignals(False) self.env_box.sta_table.show_selected_only = False # with BlockSignalsContext(self.env_box.sta_table): - self.env_box.sta_table.update_items( - observables, status, formulas={}, vocs_signal=False - ) + self.env_box.sta_table.update_items(observables, status, vocs_signal=False) self.env_box.fit_content() # self.routine = None @@ -1844,10 +1852,12 @@ def _compose_routine(self) -> Routine: vrange_hard_limit=vrange_hard_limit, initial_point_actions=initial_point_actions, additional_variables=self.env_box.var_table.addtl_vars, - formulas=self.env_box.obj_table.formulas, + formulas=self.env_box.objectives_list_view.formulas, constraint_formulas=self.env_box.con_table.formulas, observable_formulas=self.env_box.sta_table.formulas, ) + print(f"Compose routine: vocs: {routine.vocs}") + print(f"Compose routine: formulas: {routine.formulas}") # Check if any user warnings were caught for warning in caught_warnings: diff --git a/src/badger/gui/windows/formula_dialog.py b/src/badger/gui/windows/formula_dialog.py new file mode 100644 index 00000000..3c719821 --- /dev/null +++ b/src/badger/gui/windows/formula_dialog.py @@ -0,0 +1,371 @@ +from PyQt5.QtWidgets import ( + QDialog, + QWidget, + QHBoxLayout, + QPushButton, + QVBoxLayout, + QLabel, + QTextEdit, + QLineEdit, + QCompleter, +) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QTextCursor + +from badger.formula_utils import sanitize_for_validation, validate_formula +from badger.gui.components.editable_table_2 import ( + ObjectivesListView, + ObjectiveRowWidget, +) + + +stylesheet_run = """ +QPushButton:hover:pressed +{ + background-color: #92D38C; +} +QPushButton:hover +{ + background-color: #6EC566; +} +QPushButton +{ + background-color: #4AB640; + color: #000000; +} +""" + + +class CompleterTextEdit(QTextEdit): + def __init__(self, parent=None): + super().__init__(parent) + self._completer = None + + def setCompleter(self, completer: QCompleter): + if self._completer is completer: + return + + if self._completer is not None: + try: + self._completer.activated[str].disconnect( + self.insert_completion_backticked + ) + except TypeError: + pass + self._completer.setWidget(None) + + self._completer = completer + if self._completer is None: + return + + self._completer.setWidget(self) + self._completer.setCompletionMode(QCompleter.PopupCompletion) # dropdown + self._completer.setCaseSensitivity(Qt.CaseInsensitive) + self._completer.activated[str].connect(self.insert_completion_backticked) + + def completer(self): + return self._completer + + def _prefix_under_cursor(self): + tc = self.textCursor() + tc.select(QTextCursor.WordUnderCursor) + return tc.selectedText() + + def insert_completion_backticked(self, completion: str): + prefix = self._prefix_under_cursor() + tc = self.textCursor() + tc.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, len(prefix)) + tc.insertText(f"`{completion}`") + self.setTextCursor(tc) + + def keyPressEvent(self, e): + if self._completer is None: + super().keyPressEvent(e) + return + + # Let the completer's popup handle navigation/accept keys + if self._completer.popup().isVisible() and e.key() in ( + Qt.Key_Enter, + Qt.Key_Return, + Qt.Key_Escape, + Qt.Key_Tab, + Qt.Key_Backtab, + Qt.Key_Up, + Qt.Key_Down, + Qt.Key_PageUp, + Qt.Key_PageDown, + ): + e.ignore() + return + + super().keyPressEvent(e) + + prefix = self._prefix_under_cursor() + if not prefix: + self._completer.popup().hide() + return + + self._completer.setCompletionPrefix(prefix) + self._completer.popup().setCurrentIndex( + self._completer.completionModel().index(0, 0) + ) + + # Show popup at cursor position (dropdown list) + cr = self.cursorRect() + popup = self._completer.popup() + cr.setWidth(popup.sizeHintForColumn(0) + popup.frameWidth() * 2) + self._completer.complete(cr) + + +class BadgerFormulaDialog(QDialog): + """ + Dialog for adding formula observable in Badger. + """ + + def __init__( + self, + parent: QWidget, + table: ObjectivesListView, + ): + """ + Initialize the dialog. + + """ + super().__init__(parent) + self.setWindowFlags(self.windowFlags() | Qt.WindowMaximizeButtonHint) + + self.table = table + + self.items = self.table.items + + self.init_ui() + self.config_logic() + + def init_ui(self) -> None: + """ + Initialize the user interface. + """ + + self.setWindowTitle("Add formula") + self.setFixedWidth(360) + + root_vbox = QVBoxLayout(self) + + # Header and labels + header = QWidget() + header_hbox = QHBoxLayout(header) + header_hbox.setContentsMargins(0, 0, 0, 0) + + label = QLabel("test label info would be here") + label.setFixedWidth(360) + + header_hbox.addWidget(label) + + content_widget = QWidget() + hbox_content = QHBoxLayout(content_widget) + hbox_content.setContentsMargins(0, 0, 0, 0) + + formula_widget = self.build_formula_input() + self.help_widget = self.build_help_widget() + self.help_widget.hide() + + # Button set + button_set = QWidget() + hbox_set = QHBoxLayout(button_set) + hbox_set.setContentsMargins(0, 0, 0, 0) + self.btn_cancel = QPushButton("Cancel") + self.btn_add = QPushButton("Add") + self.btn_cancel.setFixedSize(96, 24) + self.btn_add.setFixedSize(96, 24) + hbox_set.addSpacing(114) + hbox_set.addWidget(self.btn_cancel) + hbox_set.addWidget(self.btn_add) + hbox_set.addStretch() + + hbox_content.addWidget(formula_widget) + hbox_content.addWidget(self.help_widget) + + # vbox.addWidget(header) + root_vbox.addWidget(content_widget) + root_vbox.addWidget(button_set) + + def build_formula_input(self) -> QWidget: + formula_widget = QWidget() + formula_layout = QVBoxLayout(formula_widget) + formula_layout.setContentsMargins(0, 0, 0, 0) + formula_widget.setFixedWidth(320) + + name_widget = QWidget() + name_layout = QVBoxLayout(name_widget) + name_layout.setContentsMargins(0, 0, 0, 0) + + name_header = QWidget() + name_header_layout = QHBoxLayout(name_header) + name_header_layout.setContentsMargins(0, 0, 0, 0) + + name_label = QLabel("Name: ") + + self.info_button = QPushButton("Show Info >") + self.info_button.setCheckable(True) + self.info_button.setFixedWidth(85) + + name_header_layout.addWidget(name_label) + name_header_layout.addStretch() + name_header_layout.addWidget(self.info_button) + + self.name_edit = QLineEdit() + self.name_edit.setPlaceholderText("Enter objective name") + name_layout.addWidget(name_header) + name_layout.addWidget(self.name_edit) + + formula_edit_widget = QWidget() + formula_edit_layout = QVBoxLayout(formula_edit_widget) + formula_edit_layout.setContentsMargins(0, 0, 0, 0) + formula_label = QLabel("Formula: ") + + self.formula_edit = CompleterTextEdit() + self.formula_edit.setPlaceholderText( + "Enter formula, for example mean(`f`) or np.std(`f`)**2\n" + + "Formula syntax:\n" + + " - Enter variable names in backticks: `f`\n" + + " - Use any python.statistics or numpy expression: \n" + + " - mean(`f`), std(`f`), percentile(`f`, 80)\n" + + " - operators including *, +, -, /, **\n" + ) + completer = QCompleter(self.table.item_names, self.formula_edit) + completer.setCaseSensitivity(Qt.CaseInsensitive) # ignore case + completer.setFilterMode(Qt.MatchContains) # match substring (optional) + # completer.setFilterMode(Qt.MatchStartsWith) # default behavior + + self.formula_edit.setCompleter(completer) + formula_edit_layout.addWidget(formula_label) + formula_edit_layout.addWidget(self.formula_edit) + + formula_layout.addWidget(name_widget) + formula_layout.addWidget(formula_edit_widget) + + return formula_widget + + def build_help_widget(self) -> QWidget: + help_widget = QWidget() + help_layout = QVBoxLayout(help_widget) + help_layout.setContentsMargins(0, 0, 0, 0) + help_widget.setFixedWidth(220) + help_widget.setStyleSheet(""" + border: 1px solid #455364; + background-color: #37414F; + color: LightGray; + """) + + help_label = QLabel( + "Helpful Formula Info: \n" + " \n" + " - Variable names are backticked:\n" + " `f`, `g` \n" + " \n" + " - Use any Python expression \n" + " from math, statistics, or \n" + " numpy such as: \n" + " - mean(`f`), std(`f`) \n" + " - max(`f`, `g`) \n" + " - percentile(`f`, 80) \n" + " - percentile(`f`, 50) \n" + " \n" + " - Supported operators: \n" + " +, -, *, /, ** \n" + ) + help_label.setAlignment(Qt.AlignLeft | Qt.AlignTop) + + help_layout.addWidget(help_label) + + return help_widget + + def config_logic(self) -> None: + self.btn_cancel.clicked.connect(self.cancel) + self.btn_add.clicked.connect(self.construct_formula_str) + self.info_button.clicked.connect(self.show_info_panel) + # self.stat_combo.currentTextChanged.connect(self.update_stat_formula) + # self.name_edit.textChanged.connect(self.update_stat_formula) + + def show_info_panel(self): + if self.info_button.isChecked(): + self.info_button.setText("Hide Info < ") + self.setFixedWidth(590) + self.help_widget.setVisible(True) + else: + self.info_button.setText("Show Info >") + self.setFixedWidth(360) + self.help_widget.setVisible(False) + + def construct_formula_str(self): + name = self.name_edit.text() + formula_str = self.formula_edit.toPlainText().strip() + if self.validate_formula(formula_str): # make sure formula is valid + print(f"Add formula: {name}, {formula_str}") + self.table.add_item(name, formula_str, checked=True) + self.close() + else: + print(f"invalid formula: {formula_str}") + + def validate_formula(self, expr: str) -> bool: + """ + Validate the formula expression by sanitizing it and checking if it can be parsed + using allowed symbols + """ + print(f"dialog validate formula: {expr}") + python_expr, allowed = sanitize_for_validation(expr) + try: + validate_formula(python_expr, allowed_symbols=allowed) + print("Formula valid") + return True + except ValueError: + return False + + def cancel(self): + self.close() + + +class FormulaEdit(BadgerFormulaDialog): + def __init__( + self, + parent: QWidget, + table: ObjectivesListView, + row_widget: ObjectiveRowWidget, + ): + """ + Initialize the dialog. + + """ + super().__init__(parent, table) + if not isinstance(row_widget, ObjectiveRowWidget): + raise ValueError("row_widget must be an instance of ObjectiveRowWidget") + self.row_widget = row_widget + self.item = item = row_widget.item + if item: + self.name_edit.setText(item.name) + self.formula_edit.setText(item.formula["formula_str"]) + + def init_ui(self) -> None: + super().init_ui() + self.setWindowTitle("Edit formula") + self.btn_add.setText("Save") + + def config_logic(self): + super().config_logic() + self.btn_add.clicked.disconnect() + self.btn_add.clicked.connect(self.construct_formula_str) + + def construct_formula_str(self): + name = self.name_edit.text() + formula_str = self.formula_edit.toPlainText().strip() + if self.validate_formula(formula_str): # make sure formula is valid + print(f"Update formula: {name}, {formula_str}") + if formula_str != self.item.formula["formula_str"]: + # only update formula + self.table.update_item_formula(self.row_widget.item, formula_str) + if name != self.item.name: + # only update name + self.row_widget.update_item_name(name) + self.close() + else: + print(f"invalid formula: {formula_str}") diff --git a/src/badger/routine.py b/src/badger/routine.py index a0030ccd..9ba3bb91 100644 --- a/src/badger/routine.py +++ b/src/badger/routine.py @@ -20,6 +20,7 @@ from badger.utils import curr_ts from badger.environment import BaseEnvironment, instantiate_env from badger.factory import get_env +from badger.formula_utils import expanded_formula_mapping logger = logging.getLogger(__name__) @@ -116,11 +117,34 @@ def validate_model(cls, data: Any): # create evaluator env = data["environment"] + # get formulas, expand and map to output names + formulas = data.get("formulas", {}) or {} + print(f"Create_mapping: formulas: {formulas}") + output_names = list(data["vocs"].output_names) + print(f"Create_mapping: output_names: {output_names}") + expanded_names, reverse_map = expanded_formula_mapping(data) + print(f"FORWARD: {expanded_names}") + print(f"REVERSE: {reverse_map}") + full_observables = list(expanded_names.values()) + selected_observables = [ + expanded_names[output_name] for output_name in output_names + ] + print(f"SELECTED OBSERVABLES: {selected_observables}") + def evaluate_point(point: dict): logger.debug(f"Evaluating point: {point}") point = pd.Series(point).explode().to_dict() env.set_variables(point) - obs = env.get_observables(data["vocs"].output_names) + print(f"get_observables vocs: {data['vocs'].output_names}") + print(f"get_observables full: {full_observables}") + print(f"get_observables selected: {selected_observables}") + # Get observables from env + obs = env.get_observables(selected_observables) + # map observables back to output names from routine + for expanded_name, original_name in reverse_map.items(): + if expanded_name in obs: + obs[original_name] = obs.pop(expanded_name) + ts = curr_ts() obs["timestamp"] = ts.timestamp() obs["live"] = 1 From 3745327d70a804c91d9b3defb4e4792742f216d0 Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 23 Apr 2026 10:20:46 -0700 Subject: [PATCH 02/10] support obs dict in env configs, minor formatting --- src/badger/gui/components/editable_table_2.py | 8 ++--- src/badger/gui/components/routine_page.py | 36 ++++++++++++++----- src/badger/gui/windows/formula_dialog.py | 10 +++--- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/badger/gui/components/editable_table_2.py b/src/badger/gui/components/editable_table_2.py index 80df00ff..f01ff817 100644 --- a/src/badger/gui/components/editable_table_2.py +++ b/src/badger/gui/components/editable_table_2.py @@ -36,7 +36,7 @@ def mouseDoubleClickEvent(self, event): class ObservableItem: checked: bool name: str - is_name_editable: bool = False + is_name_editable: bool = False is_formula_editable: bool = False formula: dict[str, Any] = field( default_factory=lambda: {"formula_str": None, "variable_mapping": {}} @@ -873,11 +873,7 @@ def update_item_formula(self, item: ObjectiveItem, new_formula_str: str) -> None def _on_item_formula_updated( self, item: ObjectiveItem, new_formula_str: str ) -> None: - """ - Update formula BEFORE checking for circular references. This allows - us to support self-referential formulas, where an item can reference itself as long as the circular reference is through the variable mapping and not the formula_str. For example, if we have an item A with formula_str "mean(`A`)", this is a valid self-referential formula because the variable mapping for A will be empty (since it doesn't directly reference any other items), and when we check for circular references, we will see that A does not reference itself through the variable mapping. However, if we had an item B with formula_str "mean(`A`)" and then updated A's formula_str to "mean(`B`)", this would create a circular reference because A's variable mapping would include B, and B's variable mapping would include A. - - """ + """ """ # check for duplicate formulas if new_formula_str in self.formula_strs: self.show_duplicate_warning(new_formula_str) diff --git a/src/badger/gui/components/routine_page.py b/src/badger/gui/components/routine_page.py index 635b665e..845f8eff 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -485,7 +485,9 @@ def set_options_from_template(self, template_dict: dict[str, Any]): formulas = {} objectives = [] status = {} - objectives_names_full = self.configs["observations"] + list(formulas.keys()) + objectives_names_full = list(self.configs["observations"]) + list( + formulas.keys() + ) for name in objectives_names_full: obj = {name: self.env_box.obj_table.default_info()} status[name] = False # selected @@ -518,7 +520,9 @@ def set_options_from_template(self, template_dict: dict[str, Any]): formulas = {} constraints = [] status = {} - constraints_names_full = self.configs["observations"] + list(formulas.keys()) + constraints_names_full = list(self.configs["observations"]) + list( + formulas.keys() + ) for name in constraints_names_full: cons = {name: self.env_box.con_table.default_info()} status[name] = False # selected @@ -558,7 +562,7 @@ def set_options_from_template(self, template_dict: dict[str, Any]): observables = [] status = {} observables_names_full = ( - var_names + self.configs["observations"] + list(formulas.keys()) + var_names + list(self.configs["observations"]) + list(formulas.keys()) ) for name in observables_names_full: obs = {name: []} @@ -835,7 +839,9 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): objectives = [] status = {} - objectives_names_full = self.configs["observations"] + list(formulas.keys()) + objectives_names_full = list(self.configs["observations"]) + list( + formulas.keys() + ) # objectives_names_full = list(set(self.configs["observations"]) | set(routine.vocs.objectives.keys()) | set(formulas.keys()) ) # adding routine.vocs.objectives.keys() allows for new observables to be defined in the routine which are not in the env print(f"refresh_ui: full_objectives: {objectives_names_full}") @@ -876,7 +882,9 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): formulas = {} constraints = [] status = {} - constraints_names_full = self.configs["observations"] + list(formulas.keys()) + constraints_names_full = list(self.configs["observations"]) + list( + formulas.keys() + ) for name in constraints_names_full: cons = {name: self.env_box.con_table.default_info()} status[name] = False # selected @@ -920,7 +928,7 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): observables = [] status = {} observables_names_full = ( - var_names + self.configs["observations"] + list(formulas.keys()) + var_names + list(self.configs["observations"]) + list(formulas.keys()) ) for name in observables_names_full: obs = {name: {}} @@ -1140,10 +1148,19 @@ def select_env(self, i: int): objectives = [] status = {} for name in self.configs["observations"]: - # this is where the default value is specified. It should check if this is defined in the environment!!!!! - # also why is this called cons here? default_obj = self.env_box.obj_table.default_info() + + # If defaults defined for name in environment use instead + try: + print(self.configs["observations"]) + rule = self.configs["observations"][name]["default_info"]["rule"] + default_obj = [rule] + except (KeyError, TypeError): + # Improve + pass + obj = {name: default_obj} + print(f"OBJ: {obj}") status[name] = False # selected objectives.append(obj) self.env_box.check_only_obj.blockSignals(True) @@ -1179,7 +1196,8 @@ def select_env(self, i: int): var_names = [] # do not show var names in observables until we have a fix to get_observables else: var_names = [] - for name in var_names + self.configs["observations"]: + + for name in var_names + list(self.configs["observations"]): obs = {name: {}} status[name] = False # selected observables.append(obs) diff --git a/src/badger/gui/windows/formula_dialog.py b/src/badger/gui/windows/formula_dialog.py index 3c719821..5ea5df32 100644 --- a/src/badger/gui/windows/formula_dialog.py +++ b/src/badger/gui/windows/formula_dialog.py @@ -261,13 +261,13 @@ def build_help_widget(self) -> QWidget: "Helpful Formula Info: \n" " \n" " - Variable names are backticked:\n" - " `f`, `g` \n" + " `f`, `equation_b`, `PV:NAME` \n" " \n" - " - Use any Python expression \n" - " from math, statistics, or \n" - " numpy such as: \n" + " - Use any expression from numpy,\n" + " python.statistics , or \n" + " python.math such as: \n" " - mean(`f`), std(`f`) \n" - " - max(`f`, `g`) \n" + " - max(`f`, `g`, `h`) \n" " - percentile(`f`, 80) \n" " - percentile(`f`, 50) \n" " \n" From be585a03704a6bb8f6f11b7a55e50cc12a4e94f7 Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 30 Apr 2026 15:08:09 -0700 Subject: [PATCH 03/10] UI updates, reintroduced stats, improved parsing --- src/badger/formula_utils.py | 21 +- src/badger/gui/components/editable_table_2.py | 285 ++++++++++-------- src/badger/gui/components/env_cbox.py | 21 ++ src/badger/gui/components/routine_page.py | 104 +++++-- src/badger/gui/windows/formula_dialog.py | 82 ++++- 5 files changed, 368 insertions(+), 145 deletions(-) diff --git a/src/badger/formula_utils.py b/src/badger/formula_utils.py index cbe7dc12..e081df65 100644 --- a/src/badger/formula_utils.py +++ b/src/badger/formula_utils.py @@ -28,6 +28,8 @@ ast.Name, ast.Load, ast.BitXor, + ast.List, + ast.Tuple, ) @@ -38,6 +40,17 @@ def validate_formula(expr: str, allowed_symbols: Set[str]) -> None: if not isinstance(node, _ALLOWED_NODES): raise ValueError(f'Operator "{type(node).__name__}" not allowed') + # Limit list/tuple size, prevent nested lists (only allow name, constant) + if isinstance(node, (ast.List, ast.Tuple)): + for element in node.elts: + if not isinstance(element, (ast.Name, ast.Constant)): + raise ValueError( + f"Lists/tuples can only contain simple values, not {type(element).__name__}" + ) + + if len(node.elts) > 50: # arbitrary size + raise ValueError(f"List/tuple too large: {len(node.elts)} elements") + if isinstance(node, ast.Call): fn = node.func.id if isinstance(node.func, ast.Name) else None if fn not in _ALLOWED_FUNC_NAMES: @@ -93,7 +106,8 @@ def sub(m: re.Match) -> str: # print("SUB") # print(f" var: {var}") if var not in mapping: - raise KeyError(f"Missing mapping for `{var}` in formula: {s!r}") + return f"`{var}`" # treat as base variable + # raise KeyError(f"Missing mapping for `{var}` in formula: {s!r}") # print(f" mapping: {mapping}") target = mapping[var] # print(f" target: {mapping[var]}") @@ -126,6 +140,11 @@ def expand_name(name: str) -> str: forward = {} for name in formulas: + if not formulas[name]["formula_str"]: + # using formulas to store user-added observables without formulas + # if no formula_str treat it as not a formula for mapping. + forward[name] = name + continue expanded_name = expand_name(name) forward[name] = expanded_name diff --git a/src/badger/gui/components/editable_table_2.py b/src/badger/gui/components/editable_table_2.py index f01ff817..21fdf866 100644 --- a/src/badger/gui/components/editable_table_2.py +++ b/src/badger/gui/components/editable_table_2.py @@ -15,13 +15,14 @@ from PyQt5.QtCore import pyqtSignal from typing import Any, Optional, Tuple import re +from enum import Enum, auto import logging logger = logging.getLogger(__name__) -class FormulaNameLabel(QLabel): +class FormulaNameLabel(QLineEdit): """Custom QLineEdit that emits a signal on double-click.""" double_clicked = pyqtSignal() @@ -29,18 +30,33 @@ class FormulaNameLabel(QLabel): def mouseDoubleClickEvent(self, event): """Override to emit signal on double-click.""" self.double_clicked.emit() + super().mouseDoubleClickEvent(event) +class Origin(Enum): + """ + Enum class for identifying observable origin as + defined in environment or user added. + """ + + ENVIRONMENT = auto() + USER = auto() + + @dataclass class ObservableItem: checked: bool name: str - is_name_editable: bool = False - is_formula_editable: bool = False + + origin: Origin = Origin.ENVIRONMENT # default environment + is_formula: bool = False + rename_allowed: bool = False + formula: dict[str, Any] = field( - default_factory=lambda: {"formula_str": None, "variable_mapping": {}} + default_factory=lambda: {"formula_str": "", "variable_mapping": {}} ) + stat: str = "none" @dataclass @@ -129,19 +145,31 @@ def _init_ui(self): # Name input field self.name_input = FormulaNameLabel(self.item.name) self.name_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + self.name_input.setMaximumWidth(230) + self.name_input.setCursorPosition(0) + if self.item.formula["formula_str"] or not self.item.origin == Origin.USER: + self.name_input.setReadOnly(True) + # self.name_input.setFocusPolicy(Qt.NoFocus) + + # if not self.item.formula["formula_str"]: + # self.name_input.setReadOnly(True) + # Disable editing if this is not a formula item - if not self.item.formula["formula_str"]: - # self.name_input.setReadOnly(True) - self.name_input.setStyleSheet(""" - border-radius: 0px; - border: 1px solid transparent; - padding: 0px; - """) - else: - self.name_input.setStyleSheet(""" - border: 1px solid transparent; - border-radius: 0px; - """) + # if not self.item.formula["formula_str"]: + # self.name_input.setStyleSheet(""" + # border-radius: 0px; + # border: 1px solid transparent; + # padding: 0px; + # """) + # else: + # self.name_input.setStyleSheet(""" + # border: 1px solid transparent; + # border-radius: 0px; + # """) + + # if self.item.formula["formula_str"]: + # self.name_input.setReadOnly(False) + layout.addWidget(self.name_input) # Rule combobox @@ -167,9 +195,9 @@ def _init_ui(self): ] ) # print(f"ObjectiveRowWidget stat: {self.item.stat}") - # self.stat_combo.setCurrentText(self.item.stat) + self.stat_combo.setCurrentText(self.item.stat) self.stat_combo.setFixedWidth(120) - # layout.addWidget(self.stat_combo) + layout.addWidget(self.stat_combo) # --! def _apply_style(self): @@ -184,28 +212,44 @@ def _apply_style(self): # for new variables? # border: 1px solid #356792; self.name_input.setStyleSheet(""" - - - QLabel { - color: DarkCyan; - border: 1px solid transparent; + QLineEdit { + color: LightSeaGreen; + border: 1px solid transparent; } - QLabel:hover { + + QLineEdit:hover { - color: LightSeaGreen; + border: 1px solid DarkCyan; } + """) self._update_tooltip() + elif self.item.origin == Origin.USER: + self.name_input.setStyleSheet(""" + QLineEdit { + color: darkGray; + + border: 1px solid transparent; + + } + + Label:hover { + + color: LightSeaGreen; + border: 1px solid LightSeaGreen; + } + """) else: # Read-only items should not respond to hover or clicks self.name_input.setStyleSheet(""" - border-radius: 0px; border: 1px solid transparent; padding: 0px; """) - if self.item.formula["variable_mapping"]: + if self.item.formula["formula_str"]: + self.stat_combo.setEditable(True) + self.stat_combo.setCurrentText("formula") self.stat_combo.setEnabled( False ) # disable stat selection for formula items @@ -227,8 +271,8 @@ def _connect_signals(self): self.checkbox.stateChanged.connect(self._on_checkbox_changed) self.rule_combo.currentTextChanged.connect(self._on_rule_changed) self.stat_combo.currentTextChanged.connect(self._on_stat_changed) - # self.name_input.returnPressed.connect(self._on_name_changed) - # self.name_input.editingFinished.connect(self._on_name_changed) # on focus loss + self.name_input.returnPressed.connect(self._on_name_changed) + self.name_input.editingFinished.connect(self._on_name_changed) # on focus loss # Only connect double-click signal if this is a formula item if self.item.formula["formula_str"]: self.name_input.double_clicked.connect( @@ -245,36 +289,9 @@ def _on_rule_changed(self): def _on_stat_changed(self): """Update item when statistic selection changes.""" - # self.item.formula["stat"] = self.stat_combo.currentText() + self.item.formula["stat"] = self.stat_combo.currentText() self.item.stat = self.stat_combo.currentText() - print( - f" select stat: {self.item.stat} for {self.item.name} NOT IMPLEMENTED YET" - ) - - # removing implementation for now because I need to figure more things out, can revisit - - # new_function = None - - # if not self.item.formula["formula_str"]: - # new_function = self._construct_obs_func_str(self.item.stat, self.item.name) - # elif "`" in self.item.formula["formula_str"]: - # try: - # stat_key, var_name = self._parse_stat_formula(self.item.formula["formula_str"]) - # new_function = self._construct_obs_func_str(stat_key, var_name) - # except TypeError: - # print(f"Failed to parse formula_str: {self.item.formula['formula_str']}") - # new_function = None - # return - # else: - # new_function = self._construct_obs_func_str(self.item.stat, self.item.formula["formula_str"]) - # print(f"Constructed new function from formula_str: {new_function}") - - # print(f"Stat changed for {self.item.name}, new function: {new_function}") - # NOTHING HAPPENS yes because I can't figure out how to make it work - # self.item.formula["formula_str"] = new_function - # self.item.formula["variable_mapping"] = {self.item.name: None} if new_function else {} - # if new_function: - # self.formula_updated.emit(new_function, self.item) + print(f" select stat: {self.item.stat} for {self.item.name}") def _construct_obs_func_str(self, operation: str, obj_name: str): print( @@ -327,6 +344,8 @@ def _on_name_changed(self): old_name = self.item.name new_name = self.name_input.text() + print(f"change name: {old_name} -> {new_name}") + if old_name != new_name: self.item.name = new_name self.item_renamed.emit(old_name, new_name) @@ -423,18 +442,6 @@ def _init_ui(self, additional_columns: list[str]) -> None: col_header.setFixedWidth(120) layout.addWidget(col_header) - """# Rule column header - rule_header = QLabel("Rule") - rule_header.setFixedWidth(120) - # rule_header.setStyleSheet("font-weight: bold;") - layout.addWidget(rule_header) - - # Stat column header - stat_header = QLabel("Statistic") - stat_header.setFixedWidth(120) - # rule_header.setStyleSheet("font-weight: bold;") - layout.addWidget(stat_header)""" - # Style the header self.setStyleSheet(""" background-color: #455364; @@ -463,7 +470,7 @@ def __init__(self, parent=None): main_layout.setSpacing(0) # Add header - self.header = HeaderWidget(additional_columns=["Rule"]) # , "Statistic"]) + self.header = HeaderWidget(additional_columns=["Rule", "Statistic"]) main_layout.addWidget(self.header) # Container widget to hold all row widgets @@ -485,6 +492,7 @@ def __init__(self, parent=None): # Store all items self._all_items: list[ObjectiveItem] = [] + # self._additional_observables: list[ObservableItem] = [] # keep track of new # Store currently displayed row widgets self.row_widgets: list[ObjectiveRowWidget] = [] @@ -504,6 +512,7 @@ def update_items( status: dict[str, bool], formulas: dict[str, dict[str, Any]] | None = None, vocs_signal: bool = False, + env_observables: list[str] = [], ) -> None: """Update the list with objectives data. @@ -520,17 +529,23 @@ def update_items( self._all_items.clear() print("UPDATING OBJECTIVES LIST VIEW") + print(f"-- {env_observables}, {objectives}") + # Create new items from objectives for objective in objectives: print(f"... objective: {objective}") for name, rule_list in objective.items(): print(f"... name: {name}, rule_list: {rule_list}") rule = rule_list[0] if rule_list else "MINIMIZE" - # stat = rule_list[1] if len(rule_list) > 1 else "none" + stat = rule_list[1] if len(rule_list) > 1 else "none" item = ObjectiveItem( checked=status.get(name, False), name=name, rule=rule, + stat=stat, + origin=Origin.USER + if env_observables and name not in env_observables + else Origin.ENVIRONMENT, ) self._all_items.append(item) @@ -539,30 +554,13 @@ def update_items( for name in formulas: if name in self.item_names: self.items[name].formula = formulas[name] - """# check for formula_str and variable_mapping to determine if item should be editable - if item.formula["formula_str"] and item.formula["variable_mapping"]: - # This is a complete formula with variable mapping - item.is_name_editable = True - item.is_formula_editable = True - elif item.formula["formula_str"]: - # This is a formula without variable mapping, - # the formula_str is the same as the name - item.is_name_editable = True - item.is_formula_editable = False""" + if "variable_mapping" not in self.items[name].formula: + self.items[name].formula["variable_mapping"] = {} else: # If formula name is not in items, add it as a new item print("HMM I DON'T THINK THIS SHOULD PRINT") - """formula_item = FormulaItem(name=name) - formula_item.selected = False # start unselected by default - formula_item.info = self.default_info() - formula_item.formula = { - "formula_str": name, - "variable_mapping": name, - } - self.formulaItems[name] = formula_item""" - # Rebuild view with current filters self._rebuild_view() @@ -670,7 +668,10 @@ def export_data(self): print( f"Exporting data for selected items: {[item.name for item in selected_items]}" ) - return [{item.name: {"rule": item.rule}} for item in selected_items] + return [ + {item.name: {"rule": item.rule, "stat": item.stat}} + for item in selected_items + ] @property def item_names(self) -> list[str]: @@ -686,9 +687,9 @@ def items(self) -> dict[str, ObjectiveItem]: def formulas(self) -> dict: _formulas = {} for item in self._all_items: - if item.formula[ - "formula_str" - ]: # "] != "none": # second part saves stat selection + if ( + item.formula["formula_str"] or item.origin == Origin.USER + ): # not sure if this is a good approach _formulas[item.name] = item.formula return _formulas @@ -711,12 +712,14 @@ def show_duplicate_warning(self, name: str) -> None: def add_item( self, name: str, formula_str: str = None, checked: bool = False ) -> None: - """Add a new objective item to the list. + """Add a new observable item to the list. This is called + either by formula dialog (add formula, with formula_str) + or by adding a new observable from the new item line. Parameters ---------- name : str - The name of the new objective. + The name of the new observable. """ if name in self.item_names: # If an item with the same name already exists, show a warning and do not add @@ -733,28 +736,31 @@ def add_item( self.show_duplicate_warning(formula_str) return - print(f"Adding new item: {name}, formula: {formula_str}") + print( + f"ObjectivesListView add_item: {name}, formula: {formula_str}, no mapping yet" + ) # Create new objective item with default rule and formula new_item = ObjectiveItem( checked=checked, name=name, rule="MINIMIZE", + origin=Origin.USER, ) if formula_str: # add item as formula new_item.formula["formula_str"] = formula_str new_item.formula["variable_mapping"] = self.get_variable_mapping( - formula_str, - current_name=name, + formula_str ) else: - new_item.formula["formula_str"] = name + # Item added from UI line as new observable with no formula. if "`" in name: - new_item.formula["variable_mapping"] = self.get_variable_mapping( - name, current_name=name - ) + new_item.formula["formula_str"] = name + new_item.formula["variable_mapping"] = self.get_variable_mapping(name) + + print(f"end add_item: {new_item.name}, {new_item.formula}") # Add to items list self._all_items.append(new_item) @@ -764,13 +770,11 @@ def add_item( # self.update_vocs() - def get_variable_mapping( - self, formula_str: str, current_name: str - ) -> dict[str, str]: + def get_variable_mapping(self, formula_str: str) -> dict[str, str]: matches = self.check_for_var_references(formula_str) # find variable references # matches is a list of variable name strings - + print(f"get_variable_mapping: matches: {matches}") visited = set() variable_mapping = {} @@ -780,18 +784,22 @@ def get_variable_mapping( continue item = self.items[match] - + print(f"match: {item.name}, {item.formula}") if item.formula["formula_str"]: # If item is formula, get full formula with variable mapping for later expansion variable_mapping[item.name] = item.formula else: # If item is not formula, map to var name variable_mapping[item.name] = item.formula["formula_str"] + + visited.add(match) print(f"match: {match}, var_map: {variable_mapping}") + print(f"return var mapping: {variable_mapping}") return variable_mapping def check_for_var_references(self, expr: str) -> list[str]: + """ if not self.item_names: return [] pat = re.compile( @@ -802,6 +810,40 @@ def check_for_var_references(self, expr: str) -> list[str]: # ) matches = pat.findall(expr) return matches + """ + + if not self.item_names: + return [] + + alts = "|".join(map(re.escape, self.item_names)) + + # Allowed token separators + left_sep = r"[()\+\-*/\s`]" + right_sep = r"[()\+\-*/\s`]" + + # regex pattern to look for matches in formula string + # formula names will be matched unless they are + # immediately before a "." or "(", so you + # can name a func "mean" and still do + # mean(`f`) without matching mean + # this will match substrings which are: + + pat = re.compile( + rf""" + + (?:(?<=^)|(?<={left_sep})) # start OR preceded by left_sep + (?:{alts}) # match name + + (?![.(]) # not followed by . or ( + (?=$|{right_sep}) # end OR right_sep or "**" after + """, + re.VERBOSE, + ) + + # (? None: """Handle renaming of an item and update references in other items. @@ -845,7 +887,8 @@ def update_item_formula(self, item: ObjectiveItem, new_formula_str: str) -> None """ - self.check_for_circular_reference(item, new_formula_str) + # self.check_for_circular_reference(item, new_formula_str) + print("UPDATE ITEM FORMULA this is different than _on_item_formula_updated") # check for duplicate formulas if new_formula_str in self.formula_strs: @@ -854,9 +897,15 @@ def update_item_formula(self, item: ObjectiveItem, new_formula_str: str) -> None item.formula["formula_str"] = new_formula_str # Recalculate variable mapping for the updated formula - item.formula["variable_mapping"] = self.get_variable_mapping( - new_formula_str, current_name=item.name - ) + mapping = self.get_variable_mapping(new_formula_str) + print(f"mapping: {mapping}") + # item.formula["variable_mapping"] = self.get_variable_mapping( + # new_formula_str, current_name=item.name + # ) + print(f"item: {item.formula['variable_mapping']}") + item.formula["variable_mapping"] = mapping + print(f"mapping: {mapping}") + print(f"update_item_formula: {item.formula['variable_mapping']}") # update variable_mapping for any items that reference this item as a variable for other_item in self._all_items: @@ -881,10 +930,8 @@ def _on_item_formula_updated( print(f"Updating formula for item {item.name}, new formula: {new_formula_str}") item.formula["formula_str"] = new_formula_str # Recalculate variable mapping for the updated formula - item.formula["variable_mapping"] = self.get_variable_mapping( - new_formula_str, current_name=item.name - ) - print(item.formula["variable_mapping"]) + item.formula["variable_mapping"] = self.get_variable_mapping(new_formula_str) + print(f"UPDATE FORMULA: {item.formula['variable_mapping']}") # update variable_mapping for any items that reference this item as a variable for other_item in self._all_items: @@ -918,7 +965,7 @@ def check_for_circular_reference( ) -> bool: # Check for circular references print("check for circular references") - print(self.get_variable_mapping(new_formula_str, item.name)) + print(self.get_variable_mapping(new_formula_str)) def find_refs(var_mapping: dict): for name, mapping in var_mapping.items(): @@ -928,7 +975,7 @@ def find_refs(var_mapping: dict): if isinstance(mapping, dict): find_refs(mapping) - find_refs(self.get_variable_mapping(new_formula_str, item.name)) + find_refs(self.get_variable_mapping(new_formula_str)) def update_vocs(self): logging.debug("Emitting data_changed signal from editable_table") diff --git a/src/badger/gui/components/env_cbox.py b/src/badger/gui/components/env_cbox.py index b7805287..2b6b2a41 100644 --- a/src/badger/gui/components/env_cbox.py +++ b/src/badger/gui/components/env_cbox.py @@ -655,6 +655,23 @@ def edit_formula(self, row_widget: ObjectiveRowWidget): finally: self.tc_dialog = None + def _construct_obs_func_str(self, operation: str, obj_name: str): + stats_mapping = { + "mean": lambda x: f"mean(`{x}`)", + "std": lambda x: f"std(`{x}`)", + "p80": lambda x: f"percentile(`{x}`, 80)", + "p75": lambda x: f"percentile(`{x}`, 75)", + "p50": lambda x: f"percentile(`{x}`, 50)", + "median": lambda x: f"percentile(`{x}`, 50)", + "p25": lambda x: f"percentile(`{x}`, 25)", + "std_rel": lambda x: f"std(`{x}`)/mean(`{x}`)", + } + + if operation in stats_mapping: + new_obj_name = stats_mapping[operation](obj_name) + print(new_obj_name) + return new_obj_name + def compose_vocs(self) -> tuple[VOCS, list[str]]: # Compose the VOCS settings variables = self.var_table.export_variables() @@ -664,6 +681,10 @@ def compose_vocs(self) -> tuple[VOCS, list[str]]: obj_name = next(iter(objective)) rule = objective[obj_name]["rule"] + stat = objective[obj_name]["stat"] + + if stat not in ["none", "formula"]: + obj_name = self._construct_obs_func_str(stat, obj_name) objectives[obj_name] = rule # [0] diff --git a/src/badger/gui/components/routine_page.py b/src/badger/gui/components/routine_page.py index 845f8eff..9a987919 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -839,41 +839,93 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): objectives = [] status = {} - objectives_names_full = list(self.configs["observations"]) + list( - formulas.keys() + objectives_names_full = list( + set( + list(self.configs["observations"]) # env observables + + list(formulas.keys()) # routine formulas + # + list(routine.vocs.objectives.keys()) # non-env (added) object + ) ) # objectives_names_full = list(set(self.configs["observations"]) | set(routine.vocs.objectives.keys()) | set(formulas.keys()) ) # adding routine.vocs.objectives.keys() allows for new observables to be defined in the routine which are not in the env print(f"refresh_ui: full_objectives: {objectives_names_full}") + print(f"refresh_ui: configs['observations']: {self.configs['observations']}") + print(f"refresh_ui: vocs objectives: {list(routine.vocs.objectives.keys())}") for name in objectives_names_full: - # if name in - obj = {name: self.env_box.obj_table.default_info()} - print(f"obj: default: {obj}") - status[name] = False # selected - objectives.append(obj) + try: + # get defaults from env configs + rule = self.configs["observations"][name]["defaults"].get( + "rule", "MINIMIZE" + ) + stat = self.configs["observations"][name]["defaults"].get( + "stat", "none" + ) + except (KeyError, TypeError): + # no default found in env configs, set to general default values + rule, stat = "MINIMIZE", "none" + + print(f"obj: default: {name}: [{rule}, {stat}]") + status[name] = False # start with not selected + objectives.append({name: [rule, stat]}) # add objective with default values + for name, val in routine.vocs.objectives.items(): + # iterate through objectives defined in routine.vocs + # this will overwrite default values with those used + # in the routine, as well as capture new observables + # which may have been added. + + stat = None # reset per objective rule = val - print(f"refresh_ui: vocs objectives: {name}") + print( + f"refresh_ui: vocs objectives: {name}, {routine.vocs.objectives[name]}" + ) + + # unwrap if not known and has backtick + if name not in objectives_names_full and "`" in name: + stat = stat_key_from_expr(name) + name = extract_variable_keys(name)[0] + print(f"refresh_ui: extracted name: {name}, stat: {stat}") + + # if still new, add it (this fixes: new observable + stat wrapper) + if name not in objectives_names_full: + print(f"new objective not in env or formulas: {name}") + objectives_names_full.append(name) + # start by adding defaults + objectives.append({name: ["MINIMIZE", "none"]}) + status[name] = False + idx = objectives_names_full.index(name) + if idx == -1: raise BadgerRoutineError( f"Objective {name} not found in the routine's observables." ) else: - objectives[idx] = {name: [rule]} + # THIS IS WHERE the stat is being lost and not added to table + # fix: + print(f"stat: {stat}") + if stat is None: + stat = "none" + + objectives[idx] = {name: [rule, stat]} status[name] = True # Show selected objectives only self.env_box.edit_obj.blockSignals(True) self.env_box.edit_obj.setText("") self.env_box.edit_obj.blockSignals(False) - self.env_box.obj_table.keyword = "" + # self.env_box.obj_table.keyword = "" # self.env_box.obj_table.show_selected_only = True print(f"refresh_ui: update items: {objectives}") - with BlockSignalsContext(self.env_box.obj_table): - self.env_box.obj_table.update_items(objectives, status, formulas) + # with BlockSignalsContext(self.env_box.obj_table): + # self.env_box.obj_table.update_items(objectives, status, formulas) with BlockSignalsContext(self.env_box.objectives_list_view): - self.env_box.objectives_list_view.update_items(objectives, status, formulas) + self.env_box.objectives_list_view.update_items( + objectives, + status, + formulas, + env_observables=self.configs["observations"], + ) # Initialize the constraints table with env observables try: @@ -1147,14 +1199,18 @@ def select_env(self, i: int): objectives = [] status = {} + formulas = {} for name in self.configs["observations"]: default_obj = self.env_box.obj_table.default_info() # If defaults defined for name in environment use instead try: - print(self.configs["observations"]) - rule = self.configs["observations"][name]["default_info"]["rule"] - default_obj = [rule] + # print(self.configs["observations"]) + rule = self.configs["observations"][name]["defaults"]["rule"] + stat = self.configs["observations"][name]["defaults"].get( + "stat", "none" + ) + default_obj = [rule, stat] except (KeyError, TypeError): # Improve pass @@ -1162,16 +1218,25 @@ def select_env(self, i: int): obj = {name: default_obj} print(f"OBJ: {obj}") status[name] = False # selected + + # If configs is a dict, see if there are default formulas + # defined for observables in environemnt + if ( + isinstance(self.configs["observations"], dict) + and "formula" in self.configs["observations"][name] + ): + formulas[name] = self.configs["observations"][name]["formula"] + objectives.append(obj) self.env_box.check_only_obj.blockSignals(True) self.env_box.check_only_obj.setChecked(False) self.env_box.check_only_obj.blockSignals(False) - self.env_box.obj_table.show_selected_only = False + # self.env_box.obj_table.show_selected_only = False # with BlockSignalsContext(self.env_box.obj_table): - self.env_box.obj_table.update_items(objectives, status, vocs_signal=False) + # self.env_box.obj_table.update_items(objectives, status, vocs_signal=False) self.env_box.objectives_list_view.update_items( - objectives, status, vocs_signal=False + objectives, status, formulas, vocs_signal=False ) # Initialize the constraints table with env observables @@ -1870,6 +1935,7 @@ def _compose_routine(self) -> Routine: vrange_hard_limit=vrange_hard_limit, initial_point_actions=initial_point_actions, additional_variables=self.env_box.var_table.addtl_vars, + # additonal_observables=self.env_box.objectives_list_view.additional_observables, formulas=self.env_box.objectives_list_view.formulas, constraint_formulas=self.env_box.con_table.formulas, observable_formulas=self.env_box.sta_table.formulas, diff --git a/src/badger/gui/windows/formula_dialog.py b/src/badger/gui/windows/formula_dialog.py index 5ea5df32..234fdb5e 100644 --- a/src/badger/gui/windows/formula_dialog.py +++ b/src/badger/gui/windows/formula_dialog.py @@ -11,6 +11,7 @@ ) from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextCursor +import re from badger.formula_utils import sanitize_for_validation, validate_formula from badger.gui.components.editable_table_2 import ( @@ -36,6 +37,38 @@ """ +def _surround_with_backticks(string_list, text): + """ + Surrounds any strings from string_list found in text with backticks, + but only if not already backticked. + + Args: + string_list: List of strings to search for + text: The string to search in and modify + + Returns: + Modified text with matching strings surrounded by backticks + """ + result = text + + # Sort by length (longest first) to handle overlapping matches correctly + sorted_list = sorted(string_list, key=len, reverse=True) + + for string in sorted_list: + # Escape the string for use in regex + escaped = re.escape(string) + + # Pattern: match string NOT preceded or followed by backtick + # (? None: """ Initialize the user interface. @@ -204,6 +245,7 @@ def build_formula_input(self) -> QWidget: name_header_layout.setContentsMargins(0, 0, 0, 0) name_label = QLabel("Name: ") + name_label.setToolTip("This is what shows up on the GUI") self.info_button = QPushButton("Show Info >") self.info_button.setCheckable(True) @@ -218,10 +260,28 @@ def build_formula_input(self) -> QWidget: name_layout.addWidget(name_header) name_layout.addWidget(self.name_edit) + variable_widget = QWidget() + variable_layout = QVBoxLayout(variable_widget) + variable_layout.setContentsMargins(0, 0, 0, 0) + variable_label = QLabel("Variables: ") + variable_edit_widget = QWidget() + self.variable_edit_layout = QVBoxLayout(variable_edit_widget) + + variable_layout.addWidget(variable_label) + variable_layout.addWidget(variable_edit_widget) + formula_edit_widget = QWidget() formula_edit_layout = QVBoxLayout(formula_edit_widget) formula_edit_layout.setContentsMargins(0, 0, 0, 0) formula_label = QLabel("Formula: ") + formula_label.setToolTip( + "This is what will actually be \n" + + "passed to the interface. If \n" + + "other formulas are referenced \n" + + "they will be expanded, and any \n" + + "calculations will be done after\n" + + "data is retrieved." + ) self.formula_edit = CompleterTextEdit() self.formula_edit.setPlaceholderText( @@ -232,16 +292,20 @@ def build_formula_input(self) -> QWidget: + " - mean(`f`), std(`f`), percentile(`f`, 80)\n" + " - operators including *, +, -, /, **\n" ) + self.formula_edit.setStyleSheet(""" + color: darkGray; + """) completer = QCompleter(self.table.item_names, self.formula_edit) completer.setCaseSensitivity(Qt.CaseInsensitive) # ignore case - completer.setFilterMode(Qt.MatchContains) # match substring (optional) - # completer.setFilterMode(Qt.MatchStartsWith) # default behavior + # completer.setFilterMode(Qt.MatchContains) # match substring (optional) + completer.setFilterMode(Qt.MatchStartsWith) # default behavior self.formula_edit.setCompleter(completer) formula_edit_layout.addWidget(formula_label) formula_edit_layout.addWidget(self.formula_edit) formula_layout.addWidget(name_widget) + # formula_layout.addWidget(variable_widget) formula_layout.addWidget(formula_edit_widget) return formula_widget @@ -301,7 +365,7 @@ def construct_formula_str(self): name = self.name_edit.text() formula_str = self.formula_edit.toPlainText().strip() if self.validate_formula(formula_str): # make sure formula is valid - print(f"Add formula: {name}, {formula_str}") + print(f"formula_dialog adding formula: {name}, {formula_str}") self.table.add_item(name, formula_str, checked=True) self.close() else: @@ -313,12 +377,16 @@ def validate_formula(self, expr: str) -> bool: using allowed symbols """ print(f"dialog validate formula: {expr}") + matches = self.table.check_for_var_references(expr) + # I don't think this is still needed, allowed separately + # referencing vars in backticks and formulas without + expr = _surround_with_backticks(matches, expr) + python_expr, allowed = sanitize_for_validation(expr) try: validate_formula(python_expr, allowed_symbols=allowed) - print("Formula valid") return True - except ValueError: + except TypeError: return False def cancel(self): @@ -357,7 +425,9 @@ def config_logic(self): def construct_formula_str(self): name = self.name_edit.text() - formula_str = self.formula_edit.toPlainText().strip() + formula_str = " ".join( + self.formula_edit.toPlainText().split() + ) # strip newlines, tabs, extra spaces if self.validate_formula(formula_str): # make sure formula is valid print(f"Update formula: {name}, {formula_str}") if formula_str != self.item.formula["formula_str"]: From a90fa14f0f68f708e274b626de51baf75db4a548 Mon Sep 17 00:00:00 2001 From: michaellans Date: Fri, 1 May 2026 13:39:41 -0700 Subject: [PATCH 04/10] improved formula and variable name parsing --- src/badger/formula_utils.py | 97 ++++++++++++++++--- src/badger/gui/components/editable_table_2.py | 23 ++++- src/badger/gui/windows/formula_dialog.py | 3 + src/badger/routine.py | 4 +- 4 files changed, 108 insertions(+), 19 deletions(-) diff --git a/src/badger/formula_utils.py b/src/badger/formula_utils.py index e081df65..d045718f 100644 --- a/src/badger/formula_utils.py +++ b/src/badger/formula_utils.py @@ -5,12 +5,66 @@ import numpy as np from typing import Set, Dict, Any, Tuple -_ALLOWED_FUNC_NAMES: set[str] = { - *vars(math), - *vars(statistics), - *vars(np), +_UNSAFE_MATH_FUNCS = { + "factorial", + "gamma", + "lgamma", + "comb", + "perm", } +_SAFE_NUMPY_FUNCS = { + "mean", + "median", + "std", + "min", + "max", + "sum", + "abs", + "sqrt", + "square", + "exp", + "log", + "log10", + "log2", + "sin", + "cos", + "tan", +} + +_SAFE_BUILTINS = { + "abs", + "sum", + "min", + "max", + "round", + "int", + "float", + "bool", +} + +_ALLOWED_FUNC_NAMES: set[str] = ( + { + name + for name in dir(math) + if callable(getattr(math, name)) + and not name.startswith("_") + and name not in _UNSAFE_MATH_FUNCS + } + | { + name + for name in dir(statistics) + if callable(getattr(statistics, name)) and not name.startswith("_") + } + | _SAFE_NUMPY_FUNCS + | _SAFE_BUILTINS + | { + "pi", + "e", + "tau", + } +) + _ALLOWED_NODES = ( ast.Expression, ast.Constant, @@ -43,7 +97,7 @@ def validate_formula(expr: str, allowed_symbols: Set[str]) -> None: # Limit list/tuple size, prevent nested lists (only allow name, constant) if isinstance(node, (ast.List, ast.Tuple)): for element in node.elts: - if not isinstance(element, (ast.Name, ast.Constant)): + if not isinstance(element, (ast.Name, ast.Constant, ast.BinOp)): raise ValueError( f"Lists/tuples can only contain simple values, not {type(element).__name__}" ) @@ -58,7 +112,9 @@ def validate_formula(expr: str, allowed_symbols: Set[str]) -> None: if isinstance(node, ast.Name): if node.id not in allowed_symbols and node.id not in _ALLOWED_FUNC_NAMES: - raise ValueError(f'Unknown symbol "{node.id}"') + raise ValueError( + f'Unknown symbol "{node.id}". Use `backticks` around variable names' + ) def sanitize_for_validation(expr: str) -> tuple[str, set[str]]: @@ -93,25 +149,29 @@ def expanded_formula_mapping(data: dict) -> Tuple[Dict[str, str], Dict[str, str] formulas = data.get("formulas", {}) or {} output_names = list(data["vocs"].output_names) + base_observables = [] # keep trach of observables within formulas print(f"start expanding formulas: {formulas}") def expand_node(node: Dict[str, Any]) -> str: - # print(f"expand node? {node}") + print(f"expand node? {node}") s = node["formula_str"] mapping = node.get("variable_mapping") or {} - # print(f"mapping: {mapping}") + print(f"mapping: {mapping}") + def sub(m: re.Match) -> str: var = m.group(1) - # print("SUB") - # print(f" var: {var}") + print("SUB") + print(f" var: {var}") if var not in mapping: + base_observables.append(var) return f"`{var}`" # treat as base variable # raise KeyError(f"Missing mapping for `{var}` in formula: {s!r}") - # print(f" mapping: {mapping}") + print(f" mapping: {mapping}") target = mapping[var] - # print(f" target: {mapping[var]}") - if target is None: # base variable + print(f" target: {mapping[var]}") + if not target: # base variable + base_observables.append(var) return f"`{var}`" return f"({expand_node(target)})" @@ -129,8 +189,8 @@ def expand_name(name: str) -> str: raise KeyError(f"Unknown formula name: {name!r}") stack.add(name) - # print(f"formulas: {formulas}") - # print(f"node: {formulas[name]}") + print(f"formulas: {formulas}") + print(f"node: {formulas[name]}") out = expand_node(formulas[name]) stack.remove(name) @@ -153,11 +213,16 @@ def expand_name(name: str) -> str: # it is not a formula, should map to itself forward[output_name] = output_name + print(f"base_observables: {base_observables}") + for obs_name in base_observables: + if obs_name not in forward: + forward[obs_name] = obs_name + reverse: Dict[str, str] = {} for name, expanded in forward.items(): reverse.setdefault(expanded, name) - return forward, reverse + return forward, reverse, base_observables def stat_key_from_expr(expr: str) -> str: diff --git a/src/badger/gui/components/editable_table_2.py b/src/badger/gui/components/editable_table_2.py index 21fdf866..ed566a71 100644 --- a/src/badger/gui/components/editable_table_2.py +++ b/src/badger/gui/components/editable_table_2.py @@ -407,10 +407,20 @@ def _on_return_pressed(self): """Handle Enter key press.""" name = self.name_input.text().strip() if name: + if "`" in name: + self.show_name_warning() + return self.item_requested.emit(name) self.name_input.clear() self.name_input.setFocus() + def show_name_warning(self) -> None: + QMessageBox.warning( + self, + "Use 'add formula' button to add equations!", + "Use 'add formula' button to add equations!", + ) + class HeaderWidget(QWidget): """A custom widget representing the table header.""" @@ -585,9 +595,14 @@ def _rebuild_view(self) -> None: elif item.widget() == self.insert_row: pass + all_names = self.item_names + sorted_names = sorted(all_names) + items = self.items + # Filter and display items row_index = 0 - for item in self._all_items: + for name in sorted_names: + item = items[name] if self._passes_filters(item): row_widget = ObjectiveRowWidget(item, row_index, parent=self) row_widget.item_renamed.connect(self._on_item_renamed) @@ -676,11 +691,13 @@ def export_data(self): @property def item_names(self) -> list[str]: """Get a list of all item names.""" + # print(f"item names: {[item.name for item in self._all_items]}") return [item.name for item in self._all_items] @property def items(self) -> dict[str, ObjectiveItem]: """Get a dictionary of items keyed by name.""" + # print("items property called") return {item.name: item for item in self._all_items} @property @@ -855,6 +872,10 @@ def _on_item_renamed(self, old_name: str, new_name: str) -> None: new_name : str The new name of the item """ + if new_name in self.item_names: + self.show_duplicate_warning(new_name) + return + # Update formula_str in all items that reference the old name for item in self._all_items: if item.formula["formula_str"]: diff --git a/src/badger/gui/windows/formula_dialog.py b/src/badger/gui/windows/formula_dialog.py index 234fdb5e..1c97e9dc 100644 --- a/src/badger/gui/windows/formula_dialog.py +++ b/src/badger/gui/windows/formula_dialog.py @@ -435,6 +435,9 @@ def construct_formula_str(self): self.table.update_item_formula(self.row_widget.item, formula_str) if name != self.item.name: # only update name + if name in self.items: + print(f"Observable name {name} already exists!") + return self.row_widget.update_item_name(name) self.close() else: diff --git a/src/badger/routine.py b/src/badger/routine.py index 9ba3bb91..fd20b5f3 100644 --- a/src/badger/routine.py +++ b/src/badger/routine.py @@ -122,12 +122,12 @@ def validate_model(cls, data: Any): print(f"Create_mapping: formulas: {formulas}") output_names = list(data["vocs"].output_names) print(f"Create_mapping: output_names: {output_names}") - expanded_names, reverse_map = expanded_formula_mapping(data) + expanded_names, reverse_map, base_obs = expanded_formula_mapping(data) print(f"FORWARD: {expanded_names}") print(f"REVERSE: {reverse_map}") full_observables = list(expanded_names.values()) selected_observables = [ - expanded_names[output_name] for output_name in output_names + expanded_names[output_name] for output_name in output_names + base_obs ] print(f"SELECTED OBSERVABLES: {selected_observables}") From 1100164b14651290d958b4ab57043d355e905f10 Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 4 May 2026 13:49:00 -0700 Subject: [PATCH 05/10] Added new ObservableEdit for stats, removed dropdown --- src/badger/formula_utils.py | 18 +- src/badger/gui/components/editable_table_2.py | 126 ++++++---- src/badger/gui/components/env_cbox.py | 20 +- src/badger/gui/windows/formula_dialog.py | 228 +++++++++++++++--- src/badger/routine.py | 3 +- 5 files changed, 300 insertions(+), 95 deletions(-) diff --git a/src/badger/formula_utils.py b/src/badger/formula_utils.py index d045718f..82e13da2 100644 --- a/src/badger/formula_utils.py +++ b/src/badger/formula_utils.py @@ -2,7 +2,6 @@ import ast import math import statistics -import numpy as np from typing import Set, Dict, Any, Tuple _UNSAFE_MATH_FUNCS = { @@ -148,9 +147,16 @@ def expanded_formula_mapping(data: dict) -> Tuple[Dict[str, str], Dict[str, str] stack = set() formulas = data.get("formulas", {}) or {} + output_names = list(data["vocs"].output_names) + selected_formulas = { + sel_name: formulas[sel_name] + for sel_name in output_names + if sel_name in formulas + } base_observables = [] # keep trach of observables within formulas - print(f"start expanding formulas: {formulas}") + print(f"all formulas: {formulas}") + print(f"start expanding formulas: {selected_formulas}") def expand_node(node: Dict[str, Any]) -> str: print(f"expand node? {node}") @@ -189,9 +195,9 @@ def expand_name(name: str) -> str: raise KeyError(f"Unknown formula name: {name!r}") stack.add(name) - print(f"formulas: {formulas}") + print(f"formulas: {selected_formulas}") print(f"node: {formulas[name]}") - out = expand_node(formulas[name]) + out = expand_node(selected_formulas[name]) stack.remove(name) cache[name] = out @@ -199,8 +205,8 @@ def expand_name(name: str) -> str: forward = {} - for name in formulas: - if not formulas[name]["formula_str"]: + for name in selected_formulas: + if not selected_formulas[name]["formula_str"]: # using formulas to store user-added observables without formulas # if no formula_str treat it as not a formula for mapping. forward[name] = name diff --git a/src/badger/gui/components/editable_table_2.py b/src/badger/gui/components/editable_table_2.py index ed566a71..c287d4b1 100644 --- a/src/badger/gui/components/editable_table_2.py +++ b/src/badger/gui/components/editable_table_2.py @@ -26,6 +26,8 @@ class FormulaNameLabel(QLineEdit): """Custom QLineEdit that emits a signal on double-click.""" double_clicked = pyqtSignal() + # mouse_enter = pyqtSignal() + # mouse_leave = pyqtSignal() def mouseDoubleClickEvent(self, event): """Override to emit signal on double-click.""" @@ -33,6 +35,14 @@ def mouseDoubleClickEvent(self, event): super().mouseDoubleClickEvent(event) + # def enterEvent(self, event): + # self.mouse_enter.emit() + # super().enterEvent(event) + + # def leaveEvent(self, event): + # self.mouse_leave.emit() + # super().leaveEvent(event) + class Origin(Enum): """ @@ -113,10 +123,11 @@ class ObjectiveRowWidget(QWidget): """A custom widget representing a single objective row with checkbox, name, and rule combobox.""" item_renamed = pyqtSignal(str, str) # Emits (old_name, new_name) - formula_updated = pyqtSignal(str, ObservableItem) # Emits (new_formula_str) + formula_updated = pyqtSignal(str, ObservableItem) # Emits (new_formula_str, item) formula_double_clicked = pyqtSignal( QWidget ) # Emitted when formula name is double-clicked + obs_double_clicked = pyqtSignal(QWidget) def __init__(self, objective_item: ObjectiveItem, row_index: int, parent=None): super().__init__(parent) @@ -145,33 +156,25 @@ def _init_ui(self): # Name input field self.name_input = FormulaNameLabel(self.item.name) self.name_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - self.name_input.setMaximumWidth(230) + # self.name_input.setFixedWidth(230) self.name_input.setCursorPosition(0) - if self.item.formula["formula_str"] or not self.item.origin == Origin.USER: - self.name_input.setReadOnly(True) + # if self.item.formula["formula_str"] or not self.item.origin == Origin.USER: + self.name_input.setReadOnly(True) # Edit by double-clicking + # self.name_input.setFocusPolicy(Qt.NoFocus) # if not self.item.formula["formula_str"]: # self.name_input.setReadOnly(True) - # Disable editing if this is not a formula item - # if not self.item.formula["formula_str"]: - # self.name_input.setStyleSheet(""" - # border-radius: 0px; - # border: 1px solid transparent; - # padding: 0px; - # """) - # else: - # self.name_input.setStyleSheet(""" - # border: 1px solid transparent; - # border-radius: 0px; - # """) - - # if self.item.formula["formula_str"]: - # self.name_input.setReadOnly(False) - layout.addWidget(self.name_input) + # if not self.item.formula["formula_str"]: + # self.indicator = QLabel("*") + # self.indicator.hide() + # # self.indicator. + # layout.addWidget(self.indicator) + # layout.addStretch(stretch=0) + # Rule combobox self.rule_combo = QComboBox() self.rule_combo.addItems(["MINIMIZE", "MAXIMIZE"]) @@ -181,6 +184,7 @@ def _init_ui(self): # !-- # statistic combobox + # Not implemented self.stat_combo = QComboBox() self.stat_combo.addItems( [ @@ -197,7 +201,7 @@ def _init_ui(self): # print(f"ObjectiveRowWidget stat: {self.item.stat}") self.stat_combo.setCurrentText(self.item.stat) self.stat_combo.setFixedWidth(120) - layout.addWidget(self.stat_combo) + # layout.addWidget(self.stat_combo) # Don't add stat combo to GUI # --! def _apply_style(self): @@ -208,45 +212,59 @@ def _apply_style(self): self.setStyleSheet("background-color: #262E38;") if self.item.formula["formula_str"]: - # need to distinguish between formulas and new non-formula vars, or have a separate flag - # for new variables? - # border: 1px solid #356792; + # styling for formula items self.name_input.setStyleSheet(""" QLineEdit { color: LightSeaGreen; - border: 1px solid transparent; + border: 1px solid transparent; + } + + QLabel:hover { + color: #00CCCC } QLineEdit:hover { - border: 1px solid DarkCyan; } - - + """) - self._update_tooltip() - elif self.item.origin == Origin.USER: - self.name_input.setStyleSheet(""" - QLineEdit { - color: darkGray; + else: + # Styling for non-formula observables + self.name_input.setStyleSheet(""" + QLineEdit { + color: lightGray; border: 1px solid transparent; + } + QLabel:hover { + color: #E8E8E8; } - - Label:hover { - - color: LightSeaGreen; - border: 1px solid LightSeaGreen; + + QLineEdit:hover { + border: 1px solid Gray; } """) - else: - # Read-only items should not respond to hover or clicks - self.name_input.setStyleSheet(""" - border: 1px solid transparent; - padding: 0px; - """) - + if self.item.origin == Origin.USER: + self.name_input.setStyleSheet(""" + QLineEdit { + color: darkGray; + border: 1px solid transparent; + } + + QLabel:hover { + color: lightGray; + } + + QLineEdit:hover { + border: 1px solid Gray; + } + """) + # alternative styling with '*' indicator + # if hasattr(self, "indicator"): + # self.name_input.mouse_enter.connect(lambda: self.indicator.show()) + # self.name_input.mouse_leave.connect(lambda: self.indicator.hide()) + self._update_tooltip() if self.item.formula["formula_str"]: self.stat_combo.setEditable(True) self.stat_combo.setCurrentText("formula") @@ -259,8 +277,8 @@ def _update_tooltip(self): """Update the tooltip to display the formula_str.""" if self.item.formula["formula_str"]: self.name_input.setToolTip(f"Formula: {self.item.formula['formula_str']}") - else: - self.name_input.setToolTip("") + elif self.item.stat: + self.name_input.setToolTip(f"Statistic: {self.item.stat}") def update_formula_tooltip(self): """Public method to update the tooltip when formulas change.""" @@ -278,6 +296,10 @@ def _connect_signals(self): self.name_input.double_clicked.connect( lambda: self.formula_double_clicked.emit(self) ) + else: # if self.item.origin == Origin.USER: + self.name_input.double_clicked.connect( + lambda: self.obs_double_clicked.emit(self) + ) def _on_checkbox_changed(self): """Update item when checkbox state changes.""" @@ -466,6 +488,7 @@ class ObjectivesListView(QScrollArea): data_changed = pyqtSignal() # Signal to indicate that data has changed formula_double_clicked = pyqtSignal(ObjectiveRowWidget) + obs_double_clicked = pyqtSignal(ObjectiveRowWidget) def __init__(self, parent=None): super().__init__(parent) @@ -480,7 +503,7 @@ def __init__(self, parent=None): main_layout.setSpacing(0) # Add header - self.header = HeaderWidget(additional_columns=["Rule", "Statistic"]) + self.header = HeaderWidget(additional_columns=["Rule"]) # , "Statistic"]) main_layout.addWidget(self.header) # Container widget to hold all row widgets @@ -584,6 +607,7 @@ def _rebuild_view(self) -> None: for widget in self.row_widgets: widget.item_renamed.disconnect() widget.formula_double_clicked.disconnect() + widget.obs_double_clicked.disconnect() widget.deleteLater() self.row_widgets.clear() @@ -612,6 +636,7 @@ def _rebuild_view(self) -> None: row_widget.formula_double_clicked.connect( self.formula_double_clicked.emit ) + row_widget.obs_double_clicked.connect(self.obs_double_clicked.emit) self.row_widgets.append(row_widget) self.container_layout.addWidget(row_widget) row_index += 1 @@ -788,7 +813,6 @@ def add_item( # self.update_vocs() def get_variable_mapping(self, formula_str: str) -> dict[str, str]: - matches = self.check_for_var_references(formula_str) # find variable references # matches is a list of variable name strings print(f"get_variable_mapping: matches: {matches}") @@ -847,10 +871,10 @@ def check_for_var_references(self, expr: str) -> list[str]: pat = re.compile( rf""" - + (?:(?<=^)|(?<={left_sep})) # start OR preceded by left_sep (?:{alts}) # match name - + (?![.(]) # not followed by . or ( (?=$|{right_sep}) # end OR right_sep or "**" after """, diff --git a/src/badger/gui/components/env_cbox.py b/src/badger/gui/components/env_cbox.py index 2b6b2a41..293d175b 100644 --- a/src/badger/gui/components/env_cbox.py +++ b/src/badger/gui/components/env_cbox.py @@ -26,7 +26,11 @@ from badger.gui.components.con_table import ConstraintTable from badger.gui.components.obs_table import ObservableTable from badger.gui.components.data_table import init_data_table -from badger.gui.windows.formula_dialog import BadgerFormulaDialog, FormulaEdit +from badger.gui.windows.formula_dialog import ( + BadgerFormulaDialog, + FormulaEdit, + ObservableEdit, +) from badger.settings import init_settings from badger.gui.utils import ( MouseWheelWidgetAdjustmentGuard, @@ -534,6 +538,7 @@ def config_logic(self): lambda: self.update_vocs("objectives_list_view") ) self.objectives_list_view.formula_double_clicked.connect(self.edit_formula) + self.objectives_list_view.obs_double_clicked.connect(self.edit_obs) def update_vocs(self, origin: str): logger.debug(f"Emitting vocs_updated signal from env_cbox: {origin}") @@ -655,6 +660,19 @@ def edit_formula(self, row_widget: ObjectiveRowWidget): finally: self.tc_dialog = None + def edit_obs(self, row_widget: ObjectiveRowWidget): + print("edit observable:") + dlg = ObservableEdit( + parent=self, + table=self.objectives_list_view, + row_widget=row_widget, + ) + self.tc_dialog = dlg + try: + dlg.exec() + finally: + self.tc_dialog = None + def _construct_obs_func_str(self, operation: str, obj_name: str): stats_mapping = { "mean": lambda x: f"mean(`{x}`)", diff --git a/src/badger/gui/windows/formula_dialog.py b/src/badger/gui/windows/formula_dialog.py index 1c97e9dc..81827e86 100644 --- a/src/badger/gui/windows/formula_dialog.py +++ b/src/badger/gui/windows/formula_dialog.py @@ -8,6 +8,7 @@ QTextEdit, QLineEdit, QCompleter, + QComboBox, ) from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextCursor @@ -17,26 +18,10 @@ from badger.gui.components.editable_table_2 import ( ObjectivesListView, ObjectiveRowWidget, + Origin, ) -stylesheet_run = """ -QPushButton:hover:pressed -{ - background-color: #92D38C; -} -QPushButton:hover -{ - background-color: #6EC566; -} -QPushButton -{ - background-color: #4AB640; - color: #000000; -} -""" - - def _surround_with_backticks(string_list, text): """ Surrounds any strings from string_list found in text with backticks, @@ -188,7 +173,7 @@ def init_ui(self) -> None: """ self.setWindowTitle("Add formula") - self.setFixedWidth(360) + self.setMinimumWidth(360) root_vbox = QVBoxLayout(self) @@ -198,7 +183,7 @@ def init_ui(self) -> None: header_hbox.setContentsMargins(0, 0, 0, 0) label = QLabel("test label info would be here") - label.setFixedWidth(360) + label.setMinimumWidth(360) header_hbox.addWidget(label) @@ -210,31 +195,18 @@ def init_ui(self) -> None: self.help_widget = self.build_help_widget() self.help_widget.hide() - # Button set - button_set = QWidget() - hbox_set = QHBoxLayout(button_set) - hbox_set.setContentsMargins(0, 0, 0, 0) - self.btn_cancel = QPushButton("Cancel") - self.btn_add = QPushButton("Add") - self.btn_cancel.setFixedSize(96, 24) - self.btn_add.setFixedSize(96, 24) - hbox_set.addSpacing(114) - hbox_set.addWidget(self.btn_cancel) - hbox_set.addWidget(self.btn_add) - hbox_set.addStretch() - hbox_content.addWidget(formula_widget) hbox_content.addWidget(self.help_widget) # vbox.addWidget(header) root_vbox.addWidget(content_widget) - root_vbox.addWidget(button_set) + # root_vbox.addWidget(button_set) def build_formula_input(self) -> QWidget: formula_widget = QWidget() formula_layout = QVBoxLayout(formula_widget) formula_layout.setContentsMargins(0, 0, 0, 0) - formula_widget.setFixedWidth(320) + formula_widget.setMinimumWidth(320) name_widget = QWidget() name_layout = QVBoxLayout(name_widget) @@ -300,6 +272,19 @@ def build_formula_input(self) -> QWidget: # completer.setFilterMode(Qt.MatchContains) # match substring (optional) completer.setFilterMode(Qt.MatchStartsWith) # default behavior + # Button set + button_set = QWidget() + hbox_set = QHBoxLayout(button_set) + hbox_set.setContentsMargins(0, 0, 0, 0) + self.btn_cancel = QPushButton("Cancel") + self.btn_add = QPushButton("Add") + self.btn_cancel.setFixedSize(96, 24) + self.btn_add.setFixedSize(96, 24) + hbox_set.addSpacing(114) + hbox_set.addWidget(self.btn_cancel) + hbox_set.addWidget(self.btn_add) + hbox_set.addStretch() + self.formula_edit.setCompleter(completer) formula_edit_layout.addWidget(formula_label) formula_edit_layout.addWidget(self.formula_edit) @@ -307,6 +292,7 @@ def build_formula_input(self) -> QWidget: formula_layout.addWidget(name_widget) # formula_layout.addWidget(variable_widget) formula_layout.addWidget(formula_edit_widget) + formula_layout.addWidget(button_set) return formula_widget @@ -354,11 +340,11 @@ def config_logic(self) -> None: def show_info_panel(self): if self.info_button.isChecked(): self.info_button.setText("Hide Info < ") - self.setFixedWidth(590) + self.setMinuWidth(590) self.help_widget.setVisible(True) else: self.info_button.setText("Show Info >") - self.setFixedWidth(360) + self.setMinimumWidth(360) self.help_widget.setVisible(False) def construct_formula_str(self): @@ -442,3 +428,173 @@ def construct_formula_str(self): self.close() else: print(f"invalid formula: {formula_str}") + + +class ObservableEdit(QDialog): + """ + Dialog for adding formula observable in Badger. + """ + + def __init__( + self, + parent: QWidget, + table: ObjectivesListView, + row_widget: ObjectiveRowWidget, + ): + """ + Initialize the dialog. + + """ + super().__init__(parent) + self.setWindowFlags(self.windowFlags() | Qt.WindowMaximizeButtonHint) + + self.table = table + + self.items = self.table.items + self.variables = {} + + self.row_widget = row_widget + self.item = item = row_widget.item + + self.init_ui() + + if not isinstance(row_widget, ObjectiveRowWidget): + raise ValueError("row_widget must be an instance of ObjectiveRowWidget") + + if item: + self.name_edit.setText(item.name) + if item.origin != Origin.USER: + self.name_edit.setEnabled(False) + + self.config_logic() + + def config_logic(self) -> None: + self.btn_cancel.clicked.connect(self.cancel) + self.btn_add.clicked.connect(self.construct_formula_str) + + def init_ui(self) -> None: + """ + Initialize the user interface. + """ + + self.setWindowTitle("Observable") + self.setMinimumWidth(360) + + root_vbox = QVBoxLayout(self) + + # Header and labels + header = QWidget() + header_hbox = QHBoxLayout(header) + header_hbox.setContentsMargins(0, 0, 0, 0) + + label = QLabel("test label info would be here") + label.setMinimumWidth(360) + + header_hbox.addWidget(label) + + content_widget = QWidget() + hbox_content = QHBoxLayout(content_widget) + hbox_content.setContentsMargins(0, 0, 0, 0) + + formula_widget = self.build_formula_input() + hbox_content.addWidget(formula_widget) + root_vbox.addWidget(content_widget) + + def build_formula_input(self) -> QWidget: + formula_widget = QWidget() + formula_layout = QVBoxLayout(formula_widget) + formula_layout.setContentsMargins(0, 0, 0, 0) + formula_widget.setMinimumWidth(320) + + name_widget = QWidget() + name_layout = QVBoxLayout(name_widget) + name_layout.setContentsMargins(0, 0, 0, 0) + + name_header = QWidget() + name_header_layout = QHBoxLayout(name_header) + name_header_layout.setContentsMargins(0, 0, 0, 0) + + name_label = QLabel("Name: ") + name_label.setToolTip("This is what shows up on the GUI") + + self.name_edit = QLineEdit() + self.name_edit.setPlaceholderText("Enter objective name") + self.name_edit.setFixedWidth(320) + + name_header_layout.addWidget(name_label) + name_header_layout.addWidget(self.name_edit) + name_header_layout.addStretch() + name_layout.addWidget(name_header) + + formula_edit_widget = QWidget() + formula_edit_layout = QHBoxLayout(formula_edit_widget) + formula_edit_layout.setContentsMargins(0, 0, 0, 0) + formula_label = QLabel("Statistic: ") + + formula_edit_layout.addWidget(formula_label) + + self.stat_combo = QComboBox() + self.stat_combo.addItems( + [ + "none", + "mean", + "std", + "std_rel", + "p80", + "p75", + "median", + "p25", + ] + ) + + # print(f"ObjectiveRowWidget stat: {self.item.stat}") + self.stat_combo.setCurrentText(self.item.stat) + self.stat_combo.setFixedWidth(120) + formula_edit_layout.addWidget(self.stat_combo) + formula_edit_layout.addStretch() + + # Button set + button_set = QWidget() + hbox_set = QHBoxLayout(button_set) + hbox_set.setContentsMargins(0, 0, 0, 0) + self.btn_cancel = QPushButton("Cancel") + self.btn_add = QPushButton("Set") + self.btn_cancel.setFixedSize(96, 24) + self.btn_add.setFixedSize(96, 24) + hbox_set.addSpacing(114) + hbox_set.addWidget(self.btn_cancel) + hbox_set.addWidget(self.btn_add) + hbox_set.addStretch() + + formula_layout.addWidget(name_widget) + formula_layout.addWidget(formula_edit_widget) + formula_layout.addWidget(button_set) + + return formula_widget + + def _on_stat_changed(self): + """Update item when statistic selection changes.""" + self.item.formula["stat"] = self.stat_combo.currentText() + self.item.stat = self.stat_combo.currentText() + print(f" select stat: {self.item.stat} for {self.item.name}") + + def construct_formula_str(self): + name = self.name_edit.text() + stat = self.stat_combo.currentText() + print(f"name: {name}") + print(f"stat: {stat}") + print(f"item stat: {self.item.stat}") + if stat != self.item.stat: + # update stat + self._on_stat_changed() + if name != self.item.name: + # only update name + if name in self.items: + print(f"Observable name {name} already exists!") + return + self.row_widget.update_item_name(name) + + self.close() + + def cancel(self): + self.close() diff --git a/src/badger/routine.py b/src/badger/routine.py index fd20b5f3..82ab10e1 100644 --- a/src/badger/routine.py +++ b/src/badger/routine.py @@ -127,7 +127,8 @@ def validate_model(cls, data: Any): print(f"REVERSE: {reverse_map}") full_observables = list(expanded_names.values()) selected_observables = [ - expanded_names[output_name] for output_name in output_names + base_obs + expanded_names[output_name] + for output_name in set(output_names + base_obs) ] print(f"SELECTED OBSERVABLES: {selected_observables}") From 968694201567bb7bb1b8198cbce81619b753c7fd Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 4 May 2026 17:10:15 -0700 Subject: [PATCH 06/10] disable stats by default for backward compatibility --- src/badger/gui/components/editable_table_2.py | 42 ++++++++++++++++--- src/badger/gui/components/routine_page.py | 17 ++++++-- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/badger/gui/components/editable_table_2.py b/src/badger/gui/components/editable_table_2.py index c287d4b1..d49acd66 100644 --- a/src/badger/gui/components/editable_table_2.py +++ b/src/badger/gui/components/editable_table_2.py @@ -134,6 +134,7 @@ def __init__(self, objective_item: ObjectiveItem, row_index: int, parent=None): self.item = objective_item # print(f"Creating ObjectiveRowWidget for item: {self.item.name}, stat: {self.item.stat}") self.row_index = row_index + self.stats_enabled: bool = False # disable stats by default for better backwards compatibility self._init_ui() self._connect_signals() self._apply_style() @@ -219,16 +220,27 @@ def _apply_style(self): border: 1px solid transparent; } - QLabel:hover { - color: #00CCCC - } - QLineEdit:hover { border: 1px solid DarkCyan; } """) + elif not self.stats_enabled: + # if stats disabled, don't show indicator on mouse hover + self.name_input.setStyleSheet(""" + QLineEdit { + color: lightGray; + border: 1px solid transparent; + } + """) + if self.item.origin == Origin.USER: + self.name_input.setStyleSheet(""" + QLineEdit { + color: darkGray; + border: 1px solid transparent; + } + """) else: # Styling for non-formula observables self.name_input.setStyleSheet(""" @@ -277,13 +289,18 @@ def _update_tooltip(self): """Update the tooltip to display the formula_str.""" if self.item.formula["formula_str"]: self.name_input.setToolTip(f"Formula: {self.item.formula['formula_str']}") - elif self.item.stat: + elif self.item.stat and self.stats_enabled: self.name_input.setToolTip(f"Statistic: {self.item.stat}") def update_formula_tooltip(self): """Public method to update the tooltip when formulas change.""" self._update_tooltip() + def enable_statistics(self): + """enable statistics and reapply stylesheet""" + self.stats_enabled = True + self._apply_style() + def _connect_signals(self): """Connect UI signals to data updates.""" self.checkbox.stateChanged.connect(self._on_checkbox_changed) @@ -539,6 +556,13 @@ def __init__(self, parent=None): self.insert_row.item_requested.connect(self.add_item) self.insert_row.hide() + # flag for enable/disable stats depending on environment specifications + self.stats_enabled = False + + def enable_statistics(self): + print("enable statistics") + self.stats_enabled = True + def update_items( self, objectives: list[dict[str, Any]], @@ -636,7 +660,10 @@ def _rebuild_view(self) -> None: row_widget.formula_double_clicked.connect( self.formula_double_clicked.emit ) - row_widget.obs_double_clicked.connect(self.obs_double_clicked.emit) + # if stats not enabled, don't connect to enable editing stats + if self.stats_enabled: + row_widget.obs_double_clicked.connect(self.obs_double_clicked.emit) + row_widget.enable_statistics() self.row_widgets.append(row_widget) self.container_layout.addWidget(row_widget) row_index += 1 @@ -812,6 +839,9 @@ def add_item( # self.update_vocs() + def default_objective(self): + return ["MINIMIZE"] + def get_variable_mapping(self, formula_str: str) -> dict[str, str]: matches = self.check_for_var_references(formula_str) # find variable references # matches is a list of variable name strings diff --git a/src/badger/gui/components/routine_page.py b/src/badger/gui/components/routine_page.py index 9a987919..6d8ed406 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -1197,23 +1197,34 @@ def select_env(self, i: int): self.add_var() ) + print(f"select env configs type: {type(self.configs['observations'])}") + if isinstance(self.configs["observations"], dict): + self.env_box.objectives_list_view.enable_statistics() + # Stats is disabled by default for the objective table for now, this will enable it + # If obs is not a dictionary in env, should disable setting statistics + # as old environments will not need this feature, and it may add confusion + objectives = [] status = {} formulas = {} for name in self.configs["observations"]: - default_obj = self.env_box.obj_table.default_info() - + default_obj = self.env_box.objectives_list_view.default_objective() # If defaults defined for name in environment use instead try: # print(self.configs["observations"]) - rule = self.configs["observations"][name]["defaults"]["rule"] + rule = self.configs["observations"][name]["defaults"].get( + "rule", "MINIMIZE" + ) stat = self.configs["observations"][name]["defaults"].get( "stat", "none" ) default_obj = [rule, stat] except (KeyError, TypeError): # Improve + print("no defaults") pass + + obj = {name: default_obj} print(f"OBJ: {obj}") From 6de667c47eddf77f11faab29f1755d0c12d39420 Mon Sep 17 00:00:00 2001 From: michaellans Date: Mon, 4 May 2026 17:11:00 -0700 Subject: [PATCH 07/10] fixed ui bug --- src/badger/gui/windows/formula_dialog.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/badger/gui/windows/formula_dialog.py b/src/badger/gui/windows/formula_dialog.py index 81827e86..9b2001b3 100644 --- a/src/badger/gui/windows/formula_dialog.py +++ b/src/badger/gui/windows/formula_dialog.py @@ -173,7 +173,7 @@ def init_ui(self) -> None: """ self.setWindowTitle("Add formula") - self.setMinimumWidth(360) + self.setMinimumWidth(370) root_vbox = QVBoxLayout(self) @@ -206,7 +206,7 @@ def build_formula_input(self) -> QWidget: formula_widget = QWidget() formula_layout = QVBoxLayout(formula_widget) formula_layout.setContentsMargins(0, 0, 0, 0) - formula_widget.setMinimumWidth(320) + formula_widget.setMinimumWidth(360) name_widget = QWidget() name_layout = QVBoxLayout(name_widget) @@ -280,10 +280,10 @@ def build_formula_input(self) -> QWidget: self.btn_add = QPushButton("Add") self.btn_cancel.setFixedSize(96, 24) self.btn_add.setFixedSize(96, 24) - hbox_set.addSpacing(114) + hbox_set.addStretch() hbox_set.addWidget(self.btn_cancel) hbox_set.addWidget(self.btn_add) - hbox_set.addStretch() + # hbox_set.addStretch() self.formula_edit.setCompleter(completer) formula_edit_layout.addWidget(formula_label) @@ -300,7 +300,7 @@ def build_help_widget(self) -> QWidget: help_widget = QWidget() help_layout = QVBoxLayout(help_widget) help_layout.setContentsMargins(0, 0, 0, 0) - help_widget.setFixedWidth(220) + help_widget.setFixedWidth(230) help_widget.setStyleSheet(""" border: 1px solid #455364; background-color: #37414F; @@ -317,7 +317,7 @@ def build_help_widget(self) -> QWidget: " python.statistics , or \n" " python.math such as: \n" " - mean(`f`), std(`f`) \n" - " - max(`f`, `g`, `h`) \n" + " - max([`f`, `g`, `h`]) \n" " - percentile(`f`, 80) \n" " - percentile(`f`, 50) \n" " \n" @@ -338,13 +338,16 @@ def config_logic(self) -> None: # self.name_edit.textChanged.connect(self.update_stat_formula) def show_info_panel(self): + old_width = self.width() if self.info_button.isChecked(): self.info_button.setText("Hide Info < ") - self.setMinuWidth(590) + self.setMinimumWidth(615) + self.resize(old_width + 235, self.height()) self.help_widget.setVisible(True) else: self.info_button.setText("Show Info >") - self.setMinimumWidth(360) + self.setMinimumWidth(370) + self.resize(old_width - 235, self.height()) self.help_widget.setVisible(False) def construct_formula_str(self): From b3de51647183a81dc4dd01c8e63247808bbd4d56 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 5 May 2026 13:36:23 -0700 Subject: [PATCH 08/10] formatting and removed print statements --- src/badger/archive.py | 8 +- src/badger/formula_utils.py | 109 ++++-- src/badger/gui/components/editable_table_2.py | 365 ++++++++---------- src/badger/gui/components/env_cbox.py | 7 - src/badger/gui/components/routine_page.py | 38 +- src/badger/gui/windows/formula_dialog.py | 54 +-- src/badger/routine.py | 19 +- 7 files changed, 280 insertions(+), 320 deletions(-) diff --git a/src/badger/archive.py b/src/badger/archive.py index 3acd6ee3..ef14cafb 100644 --- a/src/badger/archive.py +++ b/src/badger/archive.py @@ -157,14 +157,16 @@ def load_run(run_fname: str) -> Routine: # TODO: create utility function to catch warnings to remove code # duplication with warnings.catch_warnings(record=True) as caught_warnings: - + # Note: replacing Routine.from_file with yaml.safe_load for loading routine yaml file + # for formulas update, since info in subdicts was not being loaded. with open(filename, "r") as f: data = yaml.safe_load(f) routine = Routine(**data) - print(f"yaml.safe_load: {routine.formulas}") + logger.debug( + f"yaml.safe_load: {routine.formulas}, routine.vocs: {routine.vocs}" + ) # routine = Routine.from_file(filename) - print(f"Routine.from_file: {routine.formulas}") # Check if any user warnings were caught for warning in caught_warnings: diff --git a/src/badger/formula_utils.py b/src/badger/formula_utils.py index 82e13da2..3d7f375e 100644 --- a/src/badger/formula_utils.py +++ b/src/badger/formula_utils.py @@ -2,7 +2,10 @@ import ast import math import statistics -from typing import Set, Dict, Any, Tuple +from typing import Set, Dict, Any, Tuple, List +import logging + +logger = logging.getLogger(__name__) _UNSAFE_MATH_FUNCS = { "factorial", @@ -87,21 +90,47 @@ def validate_formula(expr: str, allowed_symbols: Set[str]) -> None: + """ + Validates a formula expression for safe evaluation. + Adapted from https://github.com/slaclab/trace/blob/main/trace/utilities/formula_validation.py#L28 + + This function parses the expression using Python's AST and validates that: + - Only allowed operators and functions are used + - All variable names are in the allowed symbols set + - The expression is syntactically valid + + Parameters + ---------- + expr : str + The mathematical expression to validate + allowed_symbols : Set[str] + Set of allowed variable names in the expression + + Raises: + ValueError: If the expression contains: + - Disallowed AST node types or operators + - Lists/tuples with complex nested expressions (only simple values allowed) + - Lists/tuples exceeding 50 elements + - Function calls to non-allowed functions + - References to undefined symbols (not in allowed_symbols or allowed functions) + SyntaxError: If the expression cannot be parsed as valid Python. + """ tree = ast.parse(expr, mode="eval") for node in ast.walk(tree): if not isinstance(node, _ALLOWED_NODES): raise ValueError(f'Operator "{type(node).__name__}" not allowed') - # Limit list/tuple size, prevent nested lists (only allow name, constant) if isinstance(node, (ast.List, ast.Tuple)): for element in node.elts: - if not isinstance(element, (ast.Name, ast.Constant, ast.BinOp)): + if not isinstance( + element, (ast.Name, ast.Constant, ast.BinOp, ast.Call) + ): raise ValueError( f"Lists/tuples can only contain simple values, not {type(element).__name__}" ) - if len(node.elts) > 50: # arbitrary size + if len(node.elts) > 50: # arbitrary raise ValueError(f"List/tuple too large: {len(node.elts)} elements") if isinstance(node, ast.Call): @@ -134,14 +163,52 @@ def _repl(match: re.Match) -> str: return python_expr, set(mapping.values()) -VAR = re.compile(r"`([^`]+)`") # find `var` tokens +VAR = re.compile(r"`([^`]+)`") # find backticked `var` tokens -def expanded_formula_mapping(data: dict) -> Tuple[Dict[str, str], Dict[str, str]]: +def expanded_formula_mapping( + data: dict, +) -> Tuple[Dict[str, str], Dict[str, str], List[str]]: """ - Returns: - forward: {name: expanded_formula_string} - reverse: {expanded_formula_string: name} + Provide a forward and reverse mapping for formula names to expanded formula strings in terms of + environment variables. Non-formulas will map to themselves. Also returns a list of base_variables + within the formulas. + + Note this generally functions well in my testing, but may have a few bugs and would + probably be better to rewrite to use dfs dict traversal rather than recusion + + Variable substitution rules + --------------------------- + Substitutes variables marked with backticks. If the backticked name is present in + "variable_mapping", recursively expands the mapped formula. + + Otherwise, it's treated as a leaf node/base observable if: + - the name is not in node["variable_mapping"] + - the mapping value is falsy (None, "") + + Parameters + ---------- + - data: dict + Badger routine "data" + Note: this probably doesn't need the full data dict, only + data["vocs"] and data["formulas"] if present + + Returns + ------- + - forward: Dict[str, str] + Mapping from each output name to either its expanded formula string (for formulas) + or to itself (for base observables/non-formulas). + - reverse: Dict[str, str] + Reverse lookup mapping from expanded formula string (or identity name) + to the first corresponding key in forward + - base_observables: List[str] + Names of leaf/base observables referenced during expansion within formulas + + Additional Notes + ---------------- + - caches expanded names to avoid expanding the same named formula multiple times. + - Detects circular references using stack (may have bug) + """ cache: Dict[str, str] = {} stack = set() @@ -155,27 +222,20 @@ def expanded_formula_mapping(data: dict) -> Tuple[Dict[str, str], Dict[str, str] if sel_name in formulas } base_observables = [] # keep trach of observables within formulas - print(f"all formulas: {formulas}") - print(f"start expanding formulas: {selected_formulas}") + logger.debug(f"start expanding formulas: {selected_formulas}") def expand_node(node: Dict[str, Any]) -> str: - print(f"expand node? {node}") + logger.debug(f"expanding node: {node}") s = node["formula_str"] mapping = node.get("variable_mapping") or {} - print(f"mapping: {mapping}") - def sub(m: re.Match) -> str: var = m.group(1) - print("SUB") - print(f" var: {var}") if var not in mapping: base_observables.append(var) return f"`{var}`" # treat as base variable # raise KeyError(f"Missing mapping for `{var}` in formula: {s!r}") - print(f" mapping: {mapping}") target = mapping[var] - print(f" target: {mapping[var]}") if not target: # base variable base_observables.append(var) return f"`{var}`" @@ -184,7 +244,6 @@ def sub(m: re.Match) -> str: return VAR.sub(sub, s) def expand_name(name: str) -> str: - print(f"expand_name: {name}") if name in cache: # If it has already been expanded, use the cached version return cache[name] @@ -195,20 +254,17 @@ def expand_name(name: str) -> str: raise KeyError(f"Unknown formula name: {name!r}") stack.add(name) - print(f"formulas: {selected_formulas}") - print(f"node: {formulas[name]}") out = expand_node(selected_formulas[name]) stack.remove(name) - cache[name] = out + return out forward = {} for name in selected_formulas: if not selected_formulas[name]["formula_str"]: - # using formulas to store user-added observables without formulas - # if no formula_str treat it as not a formula for mapping. + # if no formula_str treat it as not a formula and map to itself. forward[name] = name continue expanded_name = expand_name(name) @@ -219,7 +275,6 @@ def expand_name(name: str) -> str: # it is not a formula, should map to itself forward[output_name] = output_name - print(f"base_observables: {base_observables}") for obs_name in base_observables: if obs_name not in forward: forward[obs_name] = obs_name @@ -232,15 +287,13 @@ def expand_name(name: str) -> str: def stat_key_from_expr(expr: str) -> str: - # Currently unused # Extract statistic key from an expression like # "std(`PV1`)/mean(`PV1`)" or "percentile(`PV1`, 90)" # use string parsing to find what the stat function is s = re.sub(r"\s+", "", expr) - ident = r"`[^`]+`" # r"`[^`]*`" # ANY content inside backticks (including empty) - # If you want at least 1 char: ident = r"`[^`]+`" + ident = r"`[^`]+`" # content with atleast 1 character inside backticks if re.fullmatch(rf"std\({ident}\)/mean\({ident}\)", s): return "std_rel" diff --git a/src/badger/gui/components/editable_table_2.py b/src/badger/gui/components/editable_table_2.py index d49acd66..61f2bdb6 100644 --- a/src/badger/gui/components/editable_table_2.py +++ b/src/badger/gui/components/editable_table_2.py @@ -26,28 +26,22 @@ class FormulaNameLabel(QLineEdit): """Custom QLineEdit that emits a signal on double-click.""" double_clicked = pyqtSignal() - # mouse_enter = pyqtSignal() - # mouse_leave = pyqtSignal() def mouseDoubleClickEvent(self, event): """Override to emit signal on double-click.""" self.double_clicked.emit() - super().mouseDoubleClickEvent(event) - - # def enterEvent(self, event): - # self.mouse_enter.emit() - # super().enterEvent(event) - - # def leaveEvent(self, event): - # self.mouse_leave.emit() - # super().leaveEvent(event) + # propagating signal can lead to unexpected UI behavior + # super().mouseDoubleClickEvent(event) class Origin(Enum): """ Enum class for identifying observable origin as defined in environment or user added. + + This makes it easy to differentiate behavior if desired, + for example making user-added observables editable """ ENVIRONMENT = auto() @@ -56,12 +50,33 @@ class Origin(Enum): @dataclass class ObservableItem: + """ + Represents a Badger Observable item row in the table. + + An ObservableItem encapsulates the data displayed for each observable + (subclassed as objective and constraint) on the GUI, including + its checked state, name, origin, optional formula for calculations, and optional + statistics operator. + + Attributes: + checked (bool): Whether the item is currently selected. + name (str): The display name of the observable item. + origin (Origin): The source of the observable item, either ENVIRONMENT or USER. + Defaults to Origin.ENVIRONMENT. + formula (dict[str, Any]): A dictionary containing formula specifications with: + - "formula_str" (str): The formula expression as a string + - "variable_mapping" (dict): Mapping of variables used in the formula + Defaults to an empty formula with no variables. + stat (str): The statistical method to apply (e.g., "mean", "sum", "max") to + apply if the observable data is expected as an array. + Defaults to "none" indicating no aggregation. + """ + checked: bool name: str - origin: Origin = Origin.ENVIRONMENT # default environment - is_formula: bool = False - rename_allowed: bool = False + # identify whether defined in environment or by user + origin: Origin = Origin.ENVIRONMENT # default to environment formula: dict[str, Any] = field( default_factory=lambda: {"formula_str": "", "variable_mapping": {}} @@ -74,6 +89,11 @@ class ObjectiveItem(ObservableItem): """ Dataclass to represent an objective row. + Inherits from ObservableItem. + + Attributes: + - rule (str): MINIMIZE or MAXIMIZE, direction of optimization + """ rule: str = "MINIMIZE" @@ -81,13 +101,24 @@ class ObjectiveItem(ObservableItem): @dataclass class ConstraintItem(ObservableItem): - """Dataclass to represent a constraint row.""" + """ + Dataclass to represent a constraint row. + + Inherits from ObservableItem + + Attributes: + - relation (str): option from ["<",">"] + - threshold (float): value for comparison + - ciritical (bool): whether to mark this as a critical constraint + + """ relation: str = "<" threshold: float = 0.0 critical: bool = False +# Regex pattern for decomposing wrapped statistics # Matches any of: # mean(`x`), std(`x`), percentile(`x`,80/75/50/25), std(`x`)/mean(`x`) _PATTERNS = [ @@ -120,10 +151,19 @@ class ConstraintItem(ObservableItem): class ObjectiveRowWidget(QWidget): - """A custom widget representing a single objective row with checkbox, name, and rule combobox.""" + """ + A widget for representing a single objective row with checkbox, name, and rule combobox. + + The ObjectiveRowWidget is the UI representaiton of the objective table rows. It uses + an ObjectiveItem self.item to store the data, updates the ObjetiveItem when the user + interacts with the UI, and updates the UI when the ObjectiveItem is modified. + + """ item_renamed = pyqtSignal(str, str) # Emits (old_name, new_name) - formula_updated = pyqtSignal(str, ObservableItem) # Emits (new_formula_str, item) + formula_updated = pyqtSignal( + str, ObservableItem + ) # Emits (new_formula_str, ObservableItem) formula_double_clicked = pyqtSignal( QWidget ) # Emitted when formula name is double-clicked @@ -131,10 +171,11 @@ class ObjectiveRowWidget(QWidget): def __init__(self, objective_item: ObjectiveItem, row_index: int, parent=None): super().__init__(parent) - self.item = objective_item - # print(f"Creating ObjectiveRowWidget for item: {self.item.name}, stat: {self.item.stat}") + self.item = objective_item # ObjectiveItem containing self.row_index = row_index - self.stats_enabled: bool = False # disable stats by default for better backwards compatibility + # disable stats by default for better backwards compatibility + self.stats_enabled: bool = False + self._init_ui() self._connect_signals() self._apply_style() @@ -144,11 +185,9 @@ def _init_ui(self): layout = QHBoxLayout(self) layout.setContentsMargins(4, 1, 1, 4) layout.setSpacing(1) - self.setStyleSheet(""" - border-radius: 0px; - """) + self.setStyleSheet("border-radius: 0px;") - # Checkbox + # Checkbox (for selecting objective) self.checkbox = QCheckBox() self.checkbox.setChecked(self.item.checked) self.checkbox.setFixedWidth(20) @@ -157,25 +196,10 @@ def _init_ui(self): # Name input field self.name_input = FormulaNameLabel(self.item.name) self.name_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - # self.name_input.setFixedWidth(230) self.name_input.setCursorPosition(0) - # if self.item.formula["formula_str"] or not self.item.origin == Origin.USER: - self.name_input.setReadOnly(True) # Edit by double-clicking - - # self.name_input.setFocusPolicy(Qt.NoFocus) - - # if not self.item.formula["formula_str"]: - # self.name_input.setReadOnly(True) - + self.name_input.setReadOnly(True) layout.addWidget(self.name_input) - # if not self.item.formula["formula_str"]: - # self.indicator = QLabel("*") - # self.indicator.hide() - # # self.indicator. - # layout.addWidget(self.indicator) - # layout.addStretch(stretch=0) - # Rule combobox self.rule_combo = QComboBox() self.rule_combo.addItems(["MINIMIZE", "MAXIMIZE"]) @@ -183,35 +207,30 @@ def _init_ui(self): self.rule_combo.setFixedWidth(120) layout.addWidget(self.rule_combo) - # !-- # statistic combobox - # Not implemented - self.stat_combo = QComboBox() - self.stat_combo.addItems( - [ - "none", - "mean", - "std", - "std_rel", - "p80", - "p75", - "median", - "p25", - ] - ) - # print(f"ObjectiveRowWidget stat: {self.item.stat}") - self.stat_combo.setCurrentText(self.item.stat) - self.stat_combo.setFixedWidth(120) + # self.stat_combo = QComboBox() + # self.stat_combo.addItems( + # ["none","mean","std","std_rel","p80","p75","median","p25"] + # ) + # self.stat_combo.setCurrentText(self.item.stat) + # self.stat_combo.setFixedWidth(120) # layout.addWidget(self.stat_combo) # Don't add stat combo to GUI - # --! def _apply_style(self): - """Apply alternating row colors.""" + """ + Apply alternating row colors to the table background. + + Also sets stylesheet for the QLineEdit depending on + whether the item is a formula, user-added observable, + or environment observable. + """ + # set background row color if self.row_index % 2 == 0: self.setStyleSheet("alternate-background-color: #262E38;") else: self.setStyleSheet("background-color: #262E38;") + # set QLineEdit stylesheet if self.item.formula["formula_str"]: # styling for formula items self.name_input.setStyleSheet(""" @@ -219,13 +238,10 @@ def _apply_style(self): color: LightSeaGreen; border: 1px solid transparent; } - QLineEdit:hover { border: 1px solid DarkCyan; } - """) - elif not self.stats_enabled: # if stats disabled, don't show indicator on mouse hover self.name_input.setStyleSheet(""" @@ -248,11 +264,6 @@ def _apply_style(self): color: lightGray; border: 1px solid transparent; } - - QLabel:hover { - color: #E8E8E8; - } - QLineEdit:hover { border: 1px solid Gray; } @@ -263,41 +274,29 @@ def _apply_style(self): color: darkGray; border: 1px solid transparent; } - - QLabel:hover { - color: lightGray; - } - QLineEdit:hover { border: 1px solid Gray; } """) - # alternative styling with '*' indicator - # if hasattr(self, "indicator"): - # self.name_input.mouse_enter.connect(lambda: self.indicator.show()) - # self.name_input.mouse_leave.connect(lambda: self.indicator.hide()) - self._update_tooltip() - if self.item.formula["formula_str"]: - self.stat_combo.setEditable(True) - self.stat_combo.setCurrentText("formula") - self.stat_combo.setEnabled( - False - ) # disable stat selection for formula items - # self.name_input.setStyleSheet("color: LightSeaGreen;") - - def _update_tooltip(self): - """Update the tooltip to display the formula_str.""" + + self.update_tooltip() + + def update_tooltip(self): + """Update the tooltip to display the current formula or statistic""" if self.item.formula["formula_str"]: self.name_input.setToolTip(f"Formula: {self.item.formula['formula_str']}") elif self.item.stat and self.stats_enabled: self.name_input.setToolTip(f"Statistic: {self.item.stat}") - def update_formula_tooltip(self): - """Public method to update the tooltip when formulas change.""" - self._update_tooltip() - def enable_statistics(self): - """enable statistics and reapply stylesheet""" + """ + Call this function to enable statistics. + + This feature is disabled by default. + If enabled supports defining 'plain' observables in the environment, which are + expected to be either arrays or single datapoints, and choosing how + that data is processed in the GUI. + """ self.stats_enabled = True self._apply_style() @@ -305,10 +304,11 @@ def _connect_signals(self): """Connect UI signals to data updates.""" self.checkbox.stateChanged.connect(self._on_checkbox_changed) self.rule_combo.currentTextChanged.connect(self._on_rule_changed) - self.stat_combo.currentTextChanged.connect(self._on_stat_changed) + # self.stat_combo.currentTextChanged.connect(self._on_stat_changed) + # currently name_input is disabled, name is edited from edit popup self.name_input.returnPressed.connect(self._on_name_changed) self.name_input.editingFinished.connect(self._on_name_changed) # on focus loss - # Only connect double-click signal if this is a formula item + if self.item.formula["formula_str"]: self.name_input.double_clicked.connect( lambda: self.formula_double_clicked.emit(self) @@ -326,37 +326,13 @@ def _on_rule_changed(self): """Update item when rule selection changes.""" self.item.rule = self.rule_combo.currentText() - def _on_stat_changed(self): - """Update item when statistic selection changes.""" - self.item.formula["stat"] = self.stat_combo.currentText() - self.item.stat = self.stat_combo.currentText() - print(f" select stat: {self.item.stat} for {self.item.name}") - - def _construct_obs_func_str(self, operation: str, obj_name: str): - print( - f"Constructing observable function string for operation: {operation}, object: {obj_name}" - ) - stats_mapping = { - "mean": lambda x: f"mean(`{x}`)", - "std": lambda x: f"std(`{x}`)", - "p80": lambda x: f"percentile(`{x}`,80)", - "p75": lambda x: f"percentile(`{x}`,75)", - "p25": lambda x: f"percentile(`{x}`,25)", - "median": lambda x: f"percentile(`{x}`,50)", - "std_rel": lambda x: f"std(`{x}`)/mean(`{x}`)", - } - - if operation in stats_mapping: - new_obj_name = stats_mapping[operation](obj_name) - print(f"Constructed new observable function string: {new_obj_name}") - return new_obj_name - # pass - def _parse_stat_formula(self, expr: str) -> Optional[Tuple[str, str]]: """ Returns (stat_key, variable_name) if expr matches one of the supported formulas, else None. """ + # No longer used this happens in routine_page + # need to test, could then remove _PATTERNS from this for key, rx in _PATTERNS: m = rx.match(expr) if m: @@ -383,8 +359,6 @@ def _on_name_changed(self): old_name = self.item.name new_name = self.name_input.text() - print(f"change name: {old_name} -> {new_name}") - if old_name != new_name: self.item.name = new_name self.item_renamed.emit(old_name, new_name) @@ -501,7 +475,10 @@ def _init_ui(self, additional_columns: list[str]) -> None: class ObjectivesListView(QScrollArea): - """A scrollable list view for displaying objectives as row widgets with filtering support.""" + """ + A scrollable list view for displaying objectives as row widgets, with filtering support. + + """ data_changed = pyqtSignal() # Signal to indicate that data has changed formula_double_clicked = pyqtSignal(ObjectiveRowWidget) @@ -542,7 +519,6 @@ def __init__(self, parent=None): # Store all items self._all_items: list[ObjectiveItem] = [] - # self._additional_observables: list[ObservableItem] = [] # keep track of new # Store currently displayed row widgets self.row_widgets: list[ObjectiveRowWidget] = [] @@ -560,7 +536,10 @@ def __init__(self, parent=None): self.stats_enabled = False def enable_statistics(self): - print("enable statistics") + """ + flag to enable stats function (remains disabled by default + unless called) + """ self.stats_enabled = True def update_items( @@ -571,28 +550,34 @@ def update_items( vocs_signal: bool = False, env_observables: list[str] = [], ) -> None: - """Update the list with objectives data. + """ + Repopulate the list with new objectives data. + This will clear the table and update all items + to match the specified objectives and formulas. Parameters ---------- - objectives : dict - Dictionary with form {name: [rule]} + objectives : list + list of objecitves specified as a dictionary with expected keys + 'name' and 'rule_list'. "name" is expected as a string, "rule_list' + is expected as a list with the first item the 'rule' ("MINIMIZE" or + "MAXIMIZE") and the second (optional) index a statistic. status : dict - Status information for each objective + Status information for each objective, whether it is selected or not + formulas : dict: + Dictionary of formulas vocs_signal : bool Whether to emit a signal (not used currently) + env_observables : List[str] + List of which observables are defined in the environment, to differentiate + user-added observables """ # Clear all items self._all_items.clear() - print("UPDATING OBJECTIVES LIST VIEW") - - print(f"-- {env_observables}, {objectives}") # Create new items from objectives for objective in objectives: - print(f"... objective: {objective}") for name, rule_list in objective.items(): - print(f"... name: {name}, rule_list: {rule_list}") rule = rule_list[0] if rule_list else "MINIMIZE" stat = rule_list[1] if len(rule_list) > 1 else "none" item = ObjectiveItem( @@ -615,8 +600,8 @@ def update_items( self.items[name].formula["variable_mapping"] = {} else: - # If formula name is not in items, add it as a new item - print("HMM I DON'T THINK THIS SHOULD PRINT") + # If formula name is not in items + logger.warning("Name not found in items") # Rebuild view with current filters self._rebuild_view() @@ -626,12 +611,14 @@ def update_items( def _rebuild_view(self) -> None: """Rebuild the visible row widgets based on current filters.""" - print("Rebuilding objectives list view...") # Clear existing displayed widgets for widget in self.row_widgets: widget.item_renamed.disconnect() widget.formula_double_clicked.disconnect() - widget.obs_double_clicked.disconnect() + try: + widget.obs_double_clicked.disconnect() + except TypeError: # no signals connected + pass widget.deleteLater() self.row_widgets.clear() @@ -732,8 +719,8 @@ def get_selected_items(self) -> list[ObjectiveItem]: def export_data(self): selected_items = self.get_selected_items() - print( - f"Exporting data for selected items: {[item.name for item in selected_items]}" + logger.info( + f"Exporting data for selected items from table: {[item.name for item in selected_items]}" ) return [ {item.name: {"rule": item.rule, "stat": item.stat}} @@ -781,7 +768,8 @@ def show_duplicate_warning(self, name: str) -> None: def add_item( self, name: str, formula_str: str = None, checked: bool = False ) -> None: - """Add a new observable item to the list. This is called + """ + Add a new observable item to the list. This is called either by formula dialog (add formula, with formula_str) or by adding a new observable from the new item line. @@ -789,13 +777,17 @@ def add_item( ---------- name : str The name of the new observable. + formula_str: str + (optional) formula string + checked: bool + Whether the item should be selected """ if name in self.item_names: # If an item with the same name already exists, show a warning and do not add self.show_duplicate_warning(name) return - # check for duplicate formulas + # check for duplicate names as formulas if name in self.formula_strs: self.show_duplicate_warning(name) return @@ -805,10 +797,6 @@ def add_item( self.show_duplicate_warning(formula_str) return - print( - f"ObjectivesListView add_item: {name}, formula: {formula_str}, no mapping yet" - ) - # Create new objective item with default rule and formula new_item = ObjectiveItem( checked=checked, @@ -829,7 +817,7 @@ def add_item( new_item.formula["formula_str"] = name new_item.formula["variable_mapping"] = self.get_variable_mapping(name) - print(f"end add_item: {new_item.name}, {new_item.formula}") + logger.debug(f"end add_item: {new_item.name}, {new_item.formula}") # Add to items list self._all_items.append(new_item) @@ -845,7 +833,6 @@ def default_objective(self): def get_variable_mapping(self, formula_str: str) -> dict[str, str]: matches = self.check_for_var_references(formula_str) # find variable references # matches is a list of variable name strings - print(f"get_variable_mapping: matches: {matches}") visited = set() variable_mapping = {} @@ -864,23 +851,22 @@ def get_variable_mapping(self, formula_str: str) -> dict[str, str]: variable_mapping[item.name] = item.formula["formula_str"] visited.add(match) - print(f"match: {match}, var_map: {variable_mapping}") - print(f"return var mapping: {variable_mapping}") + logger.info(f"Return variable mapping: {variable_mapping}") return variable_mapping def check_for_var_references(self, expr: str) -> list[str]: """ - if not self.item_names: - return [] - pat = re.compile( - rf"(? list[str]: # can name a func "mean" and still do # mean(`f`) without matching mean # this will match substrings which are: - pat = re.compile( rf""" - - (?:(?<=^)|(?<={left_sep})) # start OR preceded by left_sep - (?:{alts}) # match name - - (?![.(]) # not followed by . or ( + (?:(?<=^)|(?<={left_sep})) # start OR preceded by left_sep + (?:{alts}) # match name + (?![.(]) # not followed by . or ( (?=$|{right_sep}) # end OR right_sep or "**" after """, re.VERBOSE, @@ -917,7 +900,8 @@ def check_for_var_references(self, expr: str) -> list[str]: return pat.findall(expr) def _on_item_renamed(self, old_name: str, new_name: str) -> None: - """Handle renaming of an item and update references in other items. + """ + Handle renaming of an item and update references in other items. Parameters ---------- @@ -947,10 +931,11 @@ def _on_item_renamed(self, old_name: str, new_name: str) -> None: # Update tooltips for all visible row widgets for row_widget in self.row_widgets: - row_widget.update_formula_tooltip() + row_widget.update_tooltip() def update_item_formula(self, item: ObjectiveItem, new_formula_str: str) -> None: - """Handle updating an item's formula and recalculate its variable mapping. + """ + Handle updating an item's formula and recalculate its variable mapping. Parameters ---------- @@ -958,12 +943,9 @@ def update_item_formula(self, item: ObjectiveItem, new_formula_str: str) -> None The item whose formula is being updated new_formula_str : str The new formula string - - """ # self.check_for_circular_reference(item, new_formula_str) - print("UPDATE ITEM FORMULA this is different than _on_item_formula_updated") # check for duplicate formulas if new_formula_str in self.formula_strs: @@ -973,26 +955,18 @@ def update_item_formula(self, item: ObjectiveItem, new_formula_str: str) -> None item.formula["formula_str"] = new_formula_str # Recalculate variable mapping for the updated formula mapping = self.get_variable_mapping(new_formula_str) - print(f"mapping: {mapping}") - # item.formula["variable_mapping"] = self.get_variable_mapping( - # new_formula_str, current_name=item.name - # ) - print(f"item: {item.formula['variable_mapping']}") item.formula["variable_mapping"] = mapping - print(f"mapping: {mapping}") - print(f"update_item_formula: {item.formula['variable_mapping']}") # update variable_mapping for any items that reference this item as a variable for other_item in self._all_items: if other_item.formula["formula_str"]: - # print(f"other item: {other_item.name}, formula: {other_item.formula}") # replace old variable mapping with new formula for this item if item.name in other_item.formula["variable_mapping"]: other_item.formula["variable_mapping"][item.name] = item.formula # Update tooltips for all visible row widgets for row_widget in self.row_widgets: - row_widget.update_formula_tooltip() + row_widget.update_tooltip() def _on_item_formula_updated( self, item: ObjectiveItem, new_formula_str: str @@ -1002,11 +976,13 @@ def _on_item_formula_updated( if new_formula_str in self.formula_strs: self.show_duplicate_warning(new_formula_str) return - print(f"Updating formula for item {item.name}, new formula: {new_formula_str}") + + logger.info( + f"Updating formula for item {item.name}, new formula: {new_formula_str}" + ) item.formula["formula_str"] = new_formula_str # Recalculate variable mapping for the updated formula item.formula["variable_mapping"] = self.get_variable_mapping(new_formula_str) - print(f"UPDATE FORMULA: {item.formula['variable_mapping']}") # update variable_mapping for any items that reference this item as a variable for other_item in self._all_items: @@ -1018,24 +994,9 @@ def _on_item_formula_updated( # Update tooltips for all visible row widgets for row_widget in self.row_widgets: - row_widget.update_formula_tooltip() - - """def check_for_circular_reference(self, item: ObjectiveItem, new_formula_str: str) -> None: - var_map = self.get_variable_mapping(new_formula_str, current_name=item.name) - - def find_refs(var_mapping: dict, depth: int = 0): - for name, mapping in var_mapping.items(): - # Allow A referencing A directly in its own formula (depth == 0), - # but disallow A being reached through another dependency (depth > 0). - if name == item.name and depth > 0: - raise ValueError("Circular reference detected!") - - if isinstance(mapping, dict): - find_refs(mapping, depth + 1) - - find_refs(var_map, 0)""" + row_widget.update_tooltip() - def check_for_circular_reference( + """def check_for_circular_reference( self, item: ObjectiveItem, new_formula_str: str ) -> bool: # Check for circular references @@ -1050,7 +1011,7 @@ def find_refs(var_mapping: dict): if isinstance(mapping, dict): find_refs(mapping) - find_refs(self.get_variable_mapping(new_formula_str)) + find_refs(self.get_variable_mapping(new_formula_str))""" def update_vocs(self): logging.debug("Emitting data_changed signal from editable_table") diff --git a/src/badger/gui/components/env_cbox.py b/src/badger/gui/components/env_cbox.py index 293d175b..09b2771e 100644 --- a/src/badger/gui/components/env_cbox.py +++ b/src/badger/gui/components/env_cbox.py @@ -636,7 +636,6 @@ def update_stylesheets(self, environment=""): self.setStyleSheet(stylesheet) def add_formula(self): - print("add formula button pressed") dlg = BadgerFormulaDialog( parent=self, table=self.objectives_list_view, @@ -648,7 +647,6 @@ def add_formula(self): self.tc_dialog = None def edit_formula(self, row_widget: ObjectiveRowWidget): - print("edit formula:") dlg = FormulaEdit( parent=self, table=self.objectives_list_view, @@ -661,7 +659,6 @@ def edit_formula(self, row_widget: ObjectiveRowWidget): self.tc_dialog = None def edit_obs(self, row_widget: ObjectiveRowWidget): - print("edit observable:") dlg = ObservableEdit( parent=self, table=self.objectives_list_view, @@ -687,7 +684,6 @@ def _construct_obs_func_str(self, operation: str, obj_name: str): if operation in stats_mapping: new_obj_name = stats_mapping[operation](obj_name) - print(new_obj_name) return new_obj_name def compose_vocs(self) -> tuple[VOCS, list[str]]: @@ -728,9 +724,6 @@ def compose_vocs(self) -> tuple[VOCS, list[str]]: constants={}, observables=observables, ) - print( - f"VOCS composed in env_cbox: variables={list(vocs.variables.keys())}, objectives={list(vocs.objectives.keys())}, constraints={list(vocs.constraints.keys())}, observables={vocs.observables}" - ) except ValidationError as e: raise BadgerRoutineError( f"\n\nVOCS validation failed: {format_validation_error(e)}" diff --git a/src/badger/gui/components/routine_page.py b/src/badger/gui/components/routine_page.py index 6d8ed406..5ad006b8 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -843,14 +843,11 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): set( list(self.configs["observations"]) # env observables + list(formulas.keys()) # routine formulas - # + list(routine.vocs.objectives.keys()) # non-env (added) object ) ) - # objectives_names_full = list(set(self.configs["observations"]) | set(routine.vocs.objectives.keys()) | set(formulas.keys()) ) - # adding routine.vocs.objectives.keys() allows for new observables to be defined in the routine which are not in the env - print(f"refresh_ui: full_objectives: {objectives_names_full}") - print(f"refresh_ui: configs['observations']: {self.configs['observations']}") - print(f"refresh_ui: vocs objectives: {list(routine.vocs.objectives.keys())}") + # print(f"refresh_ui: full_objectives: {objectives_names_full}") + # print(f"refresh_ui: configs['observations']: {self.configs['observations']}") + # print(f"refresh_ui: vocs objectives: {list(routine.vocs.objectives.keys())}") for name in objectives_names_full: try: # get defaults from env configs @@ -861,10 +858,13 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): "stat", "none" ) except (KeyError, TypeError): - # no default found in env configs, set to general default values + # TypeError: self.configs["observations"] is a list + # KeyError: env observations does not specify default values + + # Either way no default values are specified in env configs, + # set to general defaults rule, stat = "MINIMIZE", "none" - print(f"obj: default: {name}: [{rule}, {stat}]") status[name] = False # start with not selected objectives.append({name: [rule, stat]}) # add objective with default values @@ -876,19 +876,14 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): stat = None # reset per objective rule = val - print( - f"refresh_ui: vocs objectives: {name}, {routine.vocs.objectives[name]}" - ) # unwrap if not known and has backtick if name not in objectives_names_full and "`" in name: stat = stat_key_from_expr(name) name = extract_variable_keys(name)[0] - print(f"refresh_ui: extracted name: {name}, stat: {stat}") - # if still new, add it (this fixes: new observable + stat wrapper) + # if still new, add it if name not in objectives_names_full: - print(f"new objective not in env or formulas: {name}") objectives_names_full.append(name) # start by adding defaults objectives.append({name: ["MINIMIZE", "none"]}) @@ -901,9 +896,6 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): f"Objective {name} not found in the routine's observables." ) else: - # THIS IS WHERE the stat is being lost and not added to table - # fix: - print(f"stat: {stat}") if stat is None: stat = "none" @@ -916,7 +908,6 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): self.env_box.edit_obj.blockSignals(False) # self.env_box.obj_table.keyword = "" # self.env_box.obj_table.show_selected_only = True - print(f"refresh_ui: update items: {objectives}") # with BlockSignalsContext(self.env_box.obj_table): # self.env_box.obj_table.update_items(objectives, status, formulas) with BlockSignalsContext(self.env_box.objectives_list_view): @@ -1197,7 +1188,6 @@ def select_env(self, i: int): self.add_var() ) - print(f"select env configs type: {type(self.configs['observations'])}") if isinstance(self.configs["observations"], dict): self.env_box.objectives_list_view.enable_statistics() # Stats is disabled by default for the objective table for now, this will enable it @@ -1211,7 +1201,6 @@ def select_env(self, i: int): default_obj = self.env_box.objectives_list_view.default_objective() # If defaults defined for name in environment use instead try: - # print(self.configs["observations"]) rule = self.configs["observations"][name]["defaults"].get( "rule", "MINIMIZE" ) @@ -1221,13 +1210,10 @@ def select_env(self, i: int): default_obj = [rule, stat] except (KeyError, TypeError): # Improve - print("no defaults") + logger.info("no defaults for {name}") pass - - obj = {name: default_obj} - print(f"OBJ: {obj}") status[name] = False # selected # If configs is a dict, see if there are default formulas @@ -1951,8 +1937,8 @@ def _compose_routine(self) -> Routine: constraint_formulas=self.env_box.con_table.formulas, observable_formulas=self.env_box.sta_table.formulas, ) - print(f"Compose routine: vocs: {routine.vocs}") - print(f"Compose routine: formulas: {routine.formulas}") + logger.info(f"Compose routine: vocs: {routine.vocs}") + logger.info(f"Compose routine: formulas: {routine.formulas}") # Check if any user warnings were caught for warning in caught_warnings: diff --git a/src/badger/gui/windows/formula_dialog.py b/src/badger/gui/windows/formula_dialog.py index 9b2001b3..e449b5ae 100644 --- a/src/badger/gui/windows/formula_dialog.py +++ b/src/badger/gui/windows/formula_dialog.py @@ -55,6 +55,10 @@ def _surround_with_backticks(string_list, text): class CompleterTextEdit(QTextEdit): + """ + QTextEdit with completer + """ + def __init__(self, parent=None): super().__init__(parent) self._completer = None @@ -155,18 +159,10 @@ def __init__( self.table = table self.items = self.table.items - self.variables = {} self.init_ui() self.config_logic() - self.setup_var_table() - - def setup_var_table(self): - for i, item in enumerate(self.items): - # print(item, i) - pass - def init_ui(self) -> None: """ Initialize the user interface. @@ -217,7 +213,6 @@ def build_formula_input(self) -> QWidget: name_header_layout.setContentsMargins(0, 0, 0, 0) name_label = QLabel("Name: ") - name_label.setToolTip("This is what shows up on the GUI") self.info_button = QPushButton("Show Info >") self.info_button.setCheckable(True) @@ -232,35 +227,17 @@ def build_formula_input(self) -> QWidget: name_layout.addWidget(name_header) name_layout.addWidget(self.name_edit) - variable_widget = QWidget() - variable_layout = QVBoxLayout(variable_widget) - variable_layout.setContentsMargins(0, 0, 0, 0) - variable_label = QLabel("Variables: ") - variable_edit_widget = QWidget() - self.variable_edit_layout = QVBoxLayout(variable_edit_widget) - - variable_layout.addWidget(variable_label) - variable_layout.addWidget(variable_edit_widget) - formula_edit_widget = QWidget() formula_edit_layout = QVBoxLayout(formula_edit_widget) formula_edit_layout.setContentsMargins(0, 0, 0, 0) formula_label = QLabel("Formula: ") - formula_label.setToolTip( - "This is what will actually be \n" - + "passed to the interface. If \n" - + "other formulas are referenced \n" - + "they will be expanded, and any \n" - + "calculations will be done after\n" - + "data is retrieved." - ) self.formula_edit = CompleterTextEdit() self.formula_edit.setPlaceholderText( - "Enter formula, for example mean(`f`) or np.std(`f`)**2\n" + "Enter formula, for example mean(`f`) or std(`f`)**2\n" + "Formula syntax:\n" + " - Enter variable names in backticks: `f`\n" - + " - Use any python.statistics or numpy expression: \n" + + " - Use python.statistics or numpy expressions: \n" + " - mean(`f`), std(`f`), percentile(`f`, 80)\n" + " - operators including *, +, -, /, **\n" ) @@ -269,7 +246,6 @@ def build_formula_input(self) -> QWidget: """) completer = QCompleter(self.table.item_names, self.formula_edit) completer.setCaseSensitivity(Qt.CaseInsensitive) # ignore case - # completer.setFilterMode(Qt.MatchContains) # match substring (optional) completer.setFilterMode(Qt.MatchStartsWith) # default behavior # Button set @@ -290,7 +266,6 @@ def build_formula_input(self) -> QWidget: formula_edit_layout.addWidget(self.formula_edit) formula_layout.addWidget(name_widget) - # formula_layout.addWidget(variable_widget) formula_layout.addWidget(formula_edit_widget) formula_layout.addWidget(button_set) @@ -334,10 +309,11 @@ def config_logic(self) -> None: self.btn_cancel.clicked.connect(self.cancel) self.btn_add.clicked.connect(self.construct_formula_str) self.info_button.clicked.connect(self.show_info_panel) - # self.stat_combo.currentTextChanged.connect(self.update_stat_formula) - # self.name_edit.textChanged.connect(self.update_stat_formula) def show_info_panel(self): + """ + Show/hide side panel with helpful info on writing formulas + """ old_width = self.width() if self.info_button.isChecked(): self.info_button.setText("Hide Info < ") @@ -354,7 +330,6 @@ def construct_formula_str(self): name = self.name_edit.text() formula_str = self.formula_edit.toPlainText().strip() if self.validate_formula(formula_str): # make sure formula is valid - print(f"formula_dialog adding formula: {name}, {formula_str}") self.table.add_item(name, formula_str, checked=True) self.close() else: @@ -365,7 +340,6 @@ def validate_formula(self, expr: str) -> bool: Validate the formula expression by sanitizing it and checking if it can be parsed using allowed symbols """ - print(f"dialog validate formula: {expr}") matches = self.table.check_for_var_references(expr) # I don't think this is still needed, allowed separately # referencing vars in backticks and formulas without @@ -418,7 +392,6 @@ def construct_formula_str(self): self.formula_edit.toPlainText().split() ) # strip newlines, tabs, extra spaces if self.validate_formula(formula_str): # make sure formula is valid - print(f"Update formula: {name}, {formula_str}") if formula_str != self.item.formula["formula_str"]: # only update formula self.table.update_item_formula(self.row_widget.item, formula_str) @@ -454,7 +427,6 @@ def __init__( self.table = table self.items = self.table.items - self.variables = {} self.row_widget = row_widget self.item = item = row_widget.item @@ -492,7 +464,6 @@ def init_ui(self) -> None: label = QLabel("test label info would be here") label.setMinimumWidth(360) - header_hbox.addWidget(label) content_widget = QWidget() @@ -533,7 +504,6 @@ def build_formula_input(self) -> QWidget: formula_edit_layout = QHBoxLayout(formula_edit_widget) formula_edit_layout.setContentsMargins(0, 0, 0, 0) formula_label = QLabel("Statistic: ") - formula_edit_layout.addWidget(formula_label) self.stat_combo = QComboBox() @@ -550,7 +520,6 @@ def build_formula_input(self) -> QWidget: ] ) - # print(f"ObjectiveRowWidget stat: {self.item.stat}") self.stat_combo.setCurrentText(self.item.stat) self.stat_combo.setFixedWidth(120) formula_edit_layout.addWidget(self.stat_combo) @@ -579,16 +548,11 @@ def _on_stat_changed(self): """Update item when statistic selection changes.""" self.item.formula["stat"] = self.stat_combo.currentText() self.item.stat = self.stat_combo.currentText() - print(f" select stat: {self.item.stat} for {self.item.name}") def construct_formula_str(self): name = self.name_edit.text() stat = self.stat_combo.currentText() - print(f"name: {name}") - print(f"stat: {stat}") - print(f"item stat: {self.item.stat}") if stat != self.item.stat: - # update stat self._on_stat_changed() if name != self.item.name: # only update name diff --git a/src/badger/routine.py b/src/badger/routine.py index 82ab10e1..1c310755 100644 --- a/src/badger/routine.py +++ b/src/badger/routine.py @@ -119,28 +119,29 @@ def validate_model(cls, data: Any): # get formulas, expand and map to output names formulas = data.get("formulas", {}) or {} - print(f"Create_mapping: formulas: {formulas}") output_names = list(data["vocs"].output_names) - print(f"Create_mapping: output_names: {output_names}") + + # Create a mapping of names as defined in the routine to observables + # for the environment. This will expand any formulas in terms of base + # observables, and map them back to the original name. Also includes + # a list of base_obs, so that the observables within any formulas will + # be included in the stored data. expanded_names, reverse_map, base_obs = expanded_formula_mapping(data) - print(f"FORWARD: {expanded_names}") - print(f"REVERSE: {reverse_map}") - full_observables = list(expanded_names.values()) selected_observables = [ expanded_names[output_name] for output_name in set(output_names + base_obs) ] - print(f"SELECTED OBSERVABLES: {selected_observables}") + logger.debug(f"Creating formula mapping: {formulas}") + logger.debug(f"Selected observables: {selected_observables}") def evaluate_point(point: dict): logger.debug(f"Evaluating point: {point}") point = pd.Series(point).explode().to_dict() env.set_variables(point) - print(f"get_observables vocs: {data['vocs'].output_names}") - print(f"get_observables full: {full_observables}") - print(f"get_observables selected: {selected_observables}") + # Get observables from env obs = env.get_observables(selected_observables) + # map observables back to output names from routine for expanded_name, original_name in reverse_map.items(): if expanded_name in obs: From abf5a2a143b152dc086d4982cf765d80f33af298 Mon Sep 17 00:00:00 2001 From: michaellans Date: Tue, 5 May 2026 16:02:04 -0700 Subject: [PATCH 09/10] formulas save to templates, formatting --- src/badger/gui/components/editable_table_2.py | 47 +------------------ src/badger/gui/components/routine_page.py | 20 ++++---- src/badger/routine.py | 2 +- 3 files changed, 13 insertions(+), 56 deletions(-) diff --git a/src/badger/gui/components/editable_table_2.py b/src/badger/gui/components/editable_table_2.py index 61f2bdb6..b5f18650 100644 --- a/src/badger/gui/components/editable_table_2.py +++ b/src/badger/gui/components/editable_table_2.py @@ -13,7 +13,7 @@ QMessageBox, ) from PyQt5.QtCore import pyqtSignal -from typing import Any, Optional, Tuple +from typing import Any import re from enum import Enum, auto @@ -118,38 +118,6 @@ class ConstraintItem(ObservableItem): critical: bool = False -# Regex pattern for decomposing wrapped statistics -# Matches any of: -# mean(`x`), std(`x`), percentile(`x`,80/75/50/25), std(`x`)/mean(`x`) -_PATTERNS = [ - ("mean", re.compile(r"^\s*mean\s*\(\s*`(?P[^`]+)`\s*\)\s*$", re.I)), - ("std", re.compile(r"^\s*std\s*\(\s*`(?P[^`]+)`\s*\)\s*$", re.I)), - ( - "p80", - re.compile(r"^\s*percentile\s*\(\s*`(?P[^`]+)`\s*,\s*80\s*\)\s*$", re.I), - ), - ( - "p75", - re.compile(r"^\s*percentile\s*\(\s*`(?P[^`]+)`\s*,\s*75\s*\)\s*$", re.I), - ), - ( - "p25", - re.compile(r"^\s*percentile\s*\(\s*`(?P[^`]+)`\s*,\s*25\s*\)\s*$", re.I), - ), - ( - "median", - re.compile(r"^\s*percentile\s*\(\s*`(?P[^`]+)`\s*,\s*50\s*\)\s*$", re.I), - ), - ( - "std_rel", - re.compile( - r"^\s*std\s*\(\s*`(?P[^`]+)`\s*\)\s*/\s*mean\s*\(\s*`(?P=var)`\s*\)\s*$", - re.I, - ), - ), -] - - class ObjectiveRowWidget(QWidget): """ A widget for representing a single objective row with checkbox, name, and rule combobox. @@ -326,19 +294,6 @@ def _on_rule_changed(self): """Update item when rule selection changes.""" self.item.rule = self.rule_combo.currentText() - def _parse_stat_formula(self, expr: str) -> Optional[Tuple[str, str]]: - """ - Returns (stat_key, variable_name) if expr matches one of the supported formulas, - else None. - """ - # No longer used this happens in routine_page - # need to test, could then remove _PATTERNS from this - for key, rx in _PATTERNS: - m = rx.match(expr) - if m: - return key, m.group("var") - return None - def update_item_name(self, new_name: str): """ Update the name of the item. This is called to update the diff --git a/src/badger/gui/components/routine_page.py b/src/badger/gui/components/routine_page.py index 5ad006b8..bd7f6a26 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -489,7 +489,7 @@ def set_options_from_template(self, template_dict: dict[str, Any]): formulas.keys() ) for name in objectives_names_full: - obj = {name: self.env_box.obj_table.default_info()} + obj = {name: self.env_box.objectives_list_view.default_info()} status[name] = False # selected objectives.append(obj) for name, val in vocs.objectives.items(): @@ -508,9 +508,9 @@ def set_options_from_template(self, template_dict: dict[str, Any]): self.env_box.check_only_obj.blockSignals(True) self.env_box.check_only_obj.setChecked(True) self.env_box.check_only_obj.blockSignals(False) - self.env_box.obj_table.show_selected_only = True + self.env_box.objectives_list_view.show_selected_only = True - # self.env_box.obj_table.update_items(objectives, status, formulas) + self.env_box.objectives_list_view.update_items(objectives, status, formulas) # set constraints # Initialize the constraints table with env observables @@ -617,7 +617,7 @@ def generate_template_dict_from_gui(self): "vrange_limit_options": self.ratio_var_ranges, "vrange_hard_limit": self.var_hard_limit, "additional_variables": self.env_box.var_table.addtl_vars, - "formulas": self.env_box.obj_table.formulas, + "formulas": self.env_box.objectives_list_view.formulas, "constraint_formulas": self.env_box.con_table.formulas, "observable_formulas": self.env_box.sta_table.formulas, "initial_point_actions": self.init_table_actions, @@ -929,7 +929,7 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): formulas.keys() ) for name in constraints_names_full: - cons = {name: self.env_box.con_table.default_info()} + cons = {name: ["<", 0.0, False]} status[name] = False # selected constraints.append(cons) for name, val in routine.vocs.constraints.items(): @@ -974,7 +974,7 @@ def refresh_ui(self, routine: Routine | None = None, silent: bool = False): var_names + list(self.configs["observations"]) + list(formulas.keys()) ) for name in observables_names_full: - obs = {name: {}} + obs = {name: []} status[name] = False # selected observables.append(obs) for name in routine.vocs.observables: @@ -1240,7 +1240,7 @@ def select_env(self, i: int): constraints = [] status = {} for name in self.configs["observations"]: - cons = {name: self.env_box.con_table.default_info()} + cons = {name: ["<", 0.0, False]} status[name] = False # selected constraints.append(cons) self.env_box.check_only_con.blockSignals(True) @@ -1248,7 +1248,9 @@ def select_env(self, i: int): self.env_box.check_only_con.blockSignals(False) self.env_box.con_table.show_selected_only = False # with BlockSignalsContext(self.env_box.con_table): - self.env_box.con_table.update_items(constraints, status, vocs_signal=False) + self.env_box.con_table.update_items( + constraints, status, formulas={}, vocs_signal=False + ) # Initialize the observable table with env variables and observables observables = [] @@ -1260,7 +1262,7 @@ def select_env(self, i: int): var_names = [] for name in var_names + list(self.configs["observations"]): - obs = {name: {}} + obs = {name: []} status[name] = False # selected observables.append(obs) self.env_box.check_only_sta.blockSignals(True) diff --git a/src/badger/routine.py b/src/badger/routine.py index 1c310755..24f471e9 100644 --- a/src/badger/routine.py +++ b/src/badger/routine.py @@ -125,7 +125,7 @@ def validate_model(cls, data: Any): # for the environment. This will expand any formulas in terms of base # observables, and map them back to the original name. Also includes # a list of base_obs, so that the observables within any formulas will - # be included in the stored data. + # be included in the recorded data. expanded_names, reverse_map, base_obs = expanded_formula_mapping(data) selected_observables = [ expanded_names[output_name] From 3eca4e7311bd5b8f480d6a7b3f162a6b497e510d Mon Sep 17 00:00:00 2001 From: michaellans Date: Thu, 7 May 2026 10:23:00 -0700 Subject: [PATCH 10/10] bug fix for duplicate name popup --- src/badger/gui/components/editable_table_2.py | 6 +++--- src/badger/gui/windows/formula_dialog.py | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/badger/gui/components/editable_table_2.py b/src/badger/gui/components/editable_table_2.py index b5f18650..1018bb5b 100644 --- a/src/badger/gui/components/editable_table_2.py +++ b/src/badger/gui/components/editable_table_2.py @@ -865,9 +865,9 @@ def _on_item_renamed(self, old_name: str, new_name: str) -> None: new_name : str The new name of the item """ - if new_name in self.item_names: - self.show_duplicate_warning(new_name) - return + # if new_name in self.item_names: + # self.show_duplicate_warning(new_name) + # return # Update formula_str in all items that reference the old name for item in self._all_items: diff --git a/src/badger/gui/windows/formula_dialog.py b/src/badger/gui/windows/formula_dialog.py index e449b5ae..7b7687bd 100644 --- a/src/badger/gui/windows/formula_dialog.py +++ b/src/badger/gui/windows/formula_dialog.py @@ -9,6 +9,7 @@ QLineEdit, QCompleter, QComboBox, + QMessageBox, ) from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextCursor @@ -398,7 +399,11 @@ def construct_formula_str(self): if name != self.item.name: # only update name if name in self.items: - print(f"Observable name {name} already exists!") + QMessageBox.warning( + self, + "Item already exists!", + f"Item {name} already exists!", + ) return self.row_widget.update_item_name(name) self.close()