diff --git a/src/badger/gui/components/routine_page.py b/src/badger/gui/components/routine_page.py index c7215d0c..f697ca5b 100644 --- a/src/badger/gui/components/routine_page.py +++ b/src/badger/gui/components/routine_page.py @@ -56,6 +56,7 @@ LessThanConstraint, MinimizeObjective, MaximizeObjective, + ContinuousVariable, ) from pydantic import ValidationError @@ -1526,6 +1527,7 @@ def set_ind_vrange(self, vname, config): "ratio_full": config["ratio_full"], "ratio_curr": config["ratio_curr"], "delta": config["delta"], + "exact_bounds": config.get("exact_bounds", hard_bounds), } option_idx = option["limit_option_idx"] @@ -1533,7 +1535,7 @@ def set_ind_vrange(self, vname, config): env = self.create_env() curr = env.get_variables([vname])[vname] - # 0: ratio with current value, 1: ratio with full range, 2: delta around current value + # 0: ratio with current value, 1: ratio with full range, 2: delta around current value, 3: exact bounds if option_idx == 1: ratio = option["ratio_full"] delta = 0.5 * ratio * (hard_bounds[1] - hard_bounds[0]) @@ -1543,6 +1545,9 @@ def set_ind_vrange(self, vname, config): delta = option["delta"] bounds = [curr - delta, curr + delta] bounds = np.clip(bounds, hard_bounds[0], hard_bounds[1]).tolist() + elif option_idx == 3: + bounds = sorted(option.get("exact_bounds", hard_bounds)) + bounds = np.clip(bounds, hard_bounds[0], hard_bounds[1]).tolist() else: ratio = option["ratio_curr"] sign = np.sign(curr) @@ -1629,7 +1634,7 @@ def calc_auto_bounds(self): limit_option = self.limit_option option_idx = limit_option["limit_option_idx"] - # 0: ratio with current value, 1: ratio with full range, 2: delta around current value + # 0: ratio with current value, 1: ratio with full range, 2: delta around current value, 3: exact bounds if option_idx == 1: ratio = limit_option["ratio_full"] hard_bounds = vrange[name] @@ -1645,6 +1650,13 @@ def calc_auto_bounds(self): bounds = np.clip(bounds, hard_bounds[0], hard_bounds[1]).tolist() vrange[name] = bounds logger.info(f"Auto bounds for {name} (delta): {bounds}") + elif option_idx == 3: + hard_bounds = vrange[name] + exact_bounds = limit_option.get("exact_bounds") + bounds = sorted(exact_bounds) if exact_bounds else list(hard_bounds) + bounds = np.clip(bounds, hard_bounds[0], hard_bounds[1]).tolist() + vrange[name] = bounds + logger.info(f"Auto bounds for {name} (exact): {bounds}") else: ratio = limit_option["ratio_curr"] hard_bounds = vrange[name] @@ -1744,10 +1756,17 @@ def handle_var_config(self, vname): except KeyError: option = self.limit_option + current_bounds = self.env_box.var_table.bounds.get(vname, bounds) + if current_bounds is None: + current_bounds = bounds + if isinstance(current_bounds, ContinuousVariable): + current_bounds = current_bounds.domain + configs = { "current_value": curr, "lower_bound": bounds[0], "upper_bound": bounds[1], + "current_bounds": current_bounds, **option, } diff --git a/src/badger/gui/mini/components/var_table.py b/src/badger/gui/mini/components/var_table.py index ac353204..f4cc9a23 100644 --- a/src/badger/gui/mini/components/var_table.py +++ b/src/badger/gui/mini/components/var_table.py @@ -227,16 +227,13 @@ def __init__( lower, upper = bounds delta = 0.5 * abs(upper - lower) - is_centered = self._is_centered(value, lower, upper) if is_clipped: lower_delta = abs(value - lower) upper_delta = abs(upper - value) delta = max(lower_delta, upper_delta) - self.line_edit = QLineEdit( - f"±{delta:.3f}{'*' if is_clipped or not is_centered else ''}" - ) + self.line_edit = QLineEdit(f"±{delta:.3f}{'*' if is_clipped else ''}") self.line_edit.setReadOnly(True) self.line_edit.setAlignment( Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter @@ -264,7 +261,7 @@ def __init__( layout.addWidget(self.line_edit) layout.addWidget(self._button_stack) - if is_clipped or not is_centered: + if is_clipped: self.setToolTip("Requested bounds are clipped by hardware limits") def set_selected(self, is_selected: bool): diff --git a/src/badger/gui/mini/pages/routine_page.py b/src/badger/gui/mini/pages/routine_page.py index 4bed3b91..b2cdf8da 100644 --- a/src/badger/gui/mini/pages/routine_page.py +++ b/src/badger/gui/mini/pages/routine_page.py @@ -39,6 +39,7 @@ LessThanConstraint, MaximizeObjective, MinimizeObjective, + ContinuousVariable, ) from pydantic import ValidationError @@ -1456,6 +1457,7 @@ def set_ind_vrange(self, vname, config): "ratio_full": config["ratio_full"], "ratio_curr": config["ratio_curr"], "delta": config["delta"], + "exact_bounds": config["exact_bounds"], } option_idx = option["limit_option_idx"] @@ -1463,17 +1465,24 @@ def set_ind_vrange(self, vname, config): env = self.create_env() curr = env.get_variables([vname])[vname] - # 0: ratio with current value, 1: ratio with full range, 2: delta around current value + # set bounds based on selected option if option_idx == 1: + # ratio with full range ratio = option["ratio_full"] delta = 0.5 * ratio * (hard_bounds[1] - hard_bounds[0]) bounds = [curr - delta, curr + delta] new_bounds = np.clip(bounds, hard_bounds[0], hard_bounds[1]).tolist() elif option_idx == 2: + # delta around current value delta = option["delta"] bounds = [curr - delta, curr + delta] new_bounds = np.clip(bounds, hard_bounds[0], hard_bounds[1]).tolist() + elif option_idx == 3: + # set exact bounds + bounds = option.get("exact_bounds", hard_bounds) + new_bounds = np.clip(bounds, hard_bounds[0], hard_bounds[1]).tolist() else: + # ratio around current value ratio = option["ratio_curr"] sign = np.sign(curr) bounds = [ @@ -1532,16 +1541,25 @@ def adjust_variable_range_options(self, ratio: float, var_name: str = None): # get copy of selected vrange option option = copy.copy(self.ratio_var_ranges.get(vname, self.limit_option)) option_idx = option["limit_option_idx"] - - if option_idx == 1: - key = "ratio_full" - elif option_idx == 2: - key = "delta" + if option_idx == 3: + # exact bounds: scale span by ratio around the current center. + exact_bounds = list(option.get("exact_bounds", [0.0, 0.0])) + lo, hi = sorted(exact_bounds) + center = 0.5 * (lo + hi) + half_span = 0.5 * (hi - lo) * ratio + option["exact_bounds"] = [center - half_span, center + half_span] else: - key = "ratio_curr" + # relative bounds options + if option_idx == 1: + key = "ratio_full" + elif option_idx == 2: + key = "delta" + else: + key = "ratio_curr" + + # update selected option with multiplication by ratio + option[key] = option[key] * ratio - # update selected option with multiplication by ratio - option[key] = option[key] * ratio self.ratio_var_ranges[vname] = option # recalculate bounds @@ -1623,7 +1641,7 @@ def calc_auto_bounds(self): limit_option = self.limit_option option_idx = limit_option["limit_option_idx"] - # 0: ratio with current value, 1: ratio with full range, 2: delta around current value + # 0: ratio with current value, 1: ratio with full range, 2: delta around current value, 3: exact bounds if option_idx == 1: ratio = limit_option["ratio_full"] hard_bounds = vrange[name] @@ -1641,6 +1659,14 @@ def calc_auto_bounds(self): clipped[name] = bounds != new_bounds vrange[name] = new_bounds logger.info(f"Auto bounds for {name} (delta): {new_bounds}") + elif option_idx == 3: + exact_bounds = limit_option.get("exact_bounds") + hard_bounds = vrange[name] + bounds = sorted(exact_bounds) if exact_bounds else list(hard_bounds) + new_bounds = np.clip(bounds, hard_bounds[0], hard_bounds[1]).tolist() + clipped[name] = bounds != new_bounds + vrange[name] = new_bounds + logger.info(f"Auto bounds for {name} (exact): {new_bounds}") else: ratio = limit_option["ratio_curr"] hard_bounds = vrange[name] @@ -1744,10 +1770,18 @@ def handle_var_config(self, vname): except KeyError: option = self.limit_option + current_bounds = self.env_box.var_table.bounds.get(vname, bounds) + if current_bounds is None: + # default to env bounds if bounds not set + current_bounds = bounds + if isinstance(current_bounds, ContinuousVariable): + current_bounds = current_bounds.domain + configs = { "current_value": curr, "lower_bound": bounds[0], "upper_bound": bounds[1], + "current_bounds": current_bounds, **option, } diff --git a/src/badger/gui/windows/ind_lim_vrange_dialog.py b/src/badger/gui/windows/ind_lim_vrange_dialog.py index 6f0c0db3..d0b591b1 100644 --- a/src/badger/gui/windows/ind_lim_vrange_dialog.py +++ b/src/badger/gui/windows/ind_lim_vrange_dialog.py @@ -48,6 +48,7 @@ def init_ui(self): info_group = QFrame() vbox_info = QVBoxLayout(info_group) vbox_info.setContentsMargins(2, 2, 2, 2) + vbox_info.setSpacing(2) # Current value row hbox_current = QHBoxLayout() @@ -69,6 +70,22 @@ def init_ui(self): # Add the rows to the info group vbox_info.addLayout(hbox_current) + if "current_bounds" in self.configs: + # Display current bounds + hbox_bounds = QHBoxLayout() + hbox_bounds.setContentsMargins(0, 0, 0, 0) + lbl_bounds = QLabel("Current bounds:") + lbl_bounds.setFixedWidth(128) + current_bounds = self.configs["current_bounds"] + bounds_text = f"[{current_bounds[0]:.5f}, {current_bounds[1]:.5f}]" + lbl_current_bounds = QLabel(bounds_text) + lbl_current_bounds.setStyleSheet("color: #788D9C;") + hbox_bounds.addWidget(lbl_bounds) + hbox_bounds.addStretch() + hbox_bounds.addWidget(lbl_current_bounds) + hbox_bounds.addSpacing(8) + vbox_info.addLayout(hbox_bounds) + # Add the info group to the main layout vbox.addWidget(info_group) @@ -103,6 +120,7 @@ def init_ui(self): "ratio wrt current value", "ratio wrt full range", "delta around current value", + "exact bounds", ] ) cb.setCurrentIndex(self.configs.get("limit_option_idx", 0)) @@ -155,9 +173,55 @@ def init_ui(self): hbox_delta.addWidget(lbl) hbox_delta.addWidget(sb_delta, 1) + # Absolute bounds config (used in non-relative mode) + bounds_config = QWidget() + vbox_bounds = QVBoxLayout(bounds_config) + vbox_bounds.setContentsMargins(0, 0, 0, 0) + + # Get hard limits (full variable range) + hard_lower = float(self.configs.get("lower_bound", -1e10)) + hard_upper = float(self.configs.get("upper_bound", 1e10)) + + # Get exact bounds from configs, or use current or hard bounds if not specified. + exact_bounds = list( + self.configs.get( + "exact_bounds", + self.configs.get("current_bounds", [hard_lower, hard_upper]), + ) + ) + self.configs["exact_bounds"] = exact_bounds + + hbox_bounds_lower = QHBoxLayout() + hbox_bounds_lower.setContentsMargins(0, 0, 0, 0) + lbl_lower = QLabel("Lower") + self.sb_bounds_lower = sb_bounds_lower = QDoubleSpinBox() + sb_bounds_lower.setMinimum(hard_lower) + sb_bounds_lower.setMaximum(hard_upper) + sb_bounds_lower.setDecimals(6) + sb_bounds_lower.setSingleStep((hard_upper - hard_lower) / 50) + sb_bounds_lower.setValue(exact_bounds[0]) + hbox_bounds_lower.addWidget(lbl_lower) + hbox_bounds_lower.addWidget(sb_bounds_lower, 1) + + hbox_bounds_upper = QHBoxLayout() + hbox_bounds_upper.setContentsMargins(0, 0, 0, 0) + lbl_upper = QLabel("Upper") + self.sb_bounds_upper = sb_bounds_upper = QDoubleSpinBox() + sb_bounds_upper.setMinimum(hard_lower) + sb_bounds_upper.setMaximum(hard_upper) + sb_bounds_upper.setDecimals(6) + sb_bounds_upper.setSingleStep((hard_upper - hard_lower) / 50) + sb_bounds_upper.setValue(exact_bounds[1]) + hbox_bounds_upper.addWidget(lbl_upper) + hbox_bounds_upper.addWidget(sb_bounds_upper, 1) + + vbox_bounds.addLayout(hbox_bounds_lower) + vbox_bounds.addLayout(hbox_bounds_upper) + stacks.addWidget(ratio_curr_config) stacks.addWidget(ratio_full_config) stacks.addWidget(delta_config) + stacks.addWidget(bounds_config) stacks.setCurrentIndex(self.configs.get("limit_option_idx", 0)) vbox_config.addWidget(stacks) @@ -214,6 +278,8 @@ def config_logic(self): self.sb_ratio_curr.valueChanged.connect(self.ratio_curr_changed) self.sb_ratio_full.valueChanged.connect(self.ratio_full_changed) self.sb_delta.valueChanged.connect(self.delta_changed) + self.sb_bounds_lower.valueChanged.connect(self.bounds_lower_changed) + self.sb_bounds_upper.valueChanged.connect(self.bounds_upper_changed) self.update_bounds_preview() def _clip(self, value: float, lower: float, upper: float) -> float: @@ -229,7 +295,14 @@ def update_bounds_preview(self) -> None: hard_upper = self.configs.get("upper_bound", 0) option_idx = self.cb.currentIndex() - if option_idx == 1: + if option_idx == 0: + ratio = self.sb_ratio_curr.value() + sign = math.copysign(1.0, curr) if curr != 0 else 0.0 + bounds = [ + curr * (1 - 0.5 * sign * ratio), + curr * (1 + 0.5 * sign * ratio), + ] + elif option_idx == 1: ratio = self.sb_ratio_full.value() delta = 0.5 * ratio * (hard_upper - hard_lower) bounds = [curr - delta, curr + delta] @@ -237,11 +310,9 @@ def update_bounds_preview(self) -> None: delta = self.sb_delta.value() bounds = [curr - delta, curr + delta] else: - ratio = self.sb_ratio_curr.value() - sign = math.copysign(1.0, curr) if curr != 0 else 0.0 bounds = [ - curr * (1 - 0.5 * sign * ratio), - curr * (1 + 0.5 * sign * ratio), + float(self.sb_bounds_lower.value()), + float(self.sb_bounds_upper.value()), ] bounds = [ @@ -262,10 +333,6 @@ def update_bounds_preview(self) -> None: def update_config(self): try: - # lower = float(self.lbl_hard_lower.text()) - # upper = float(self.lbl_hard_upper.text()) - # self.configs["lower_bound"] = lower - # self.configs["upper_bound"] = upper # Fill in default values if not exist if "limit_option_idx" not in self.configs: self.configs["limit_option_idx"] = self.cb.currentIndex() @@ -275,6 +342,10 @@ def update_config(self): self.configs["ratio_full"] = self.sb_ratio_full.value() if "delta" not in self.configs: self.configs["delta"] = self.sb_delta.value() + lower = float(self.sb_bounds_lower.value()) + upper = float(self.sb_bounds_upper.value()) + # Keep exact bounds ordered to avoid invalid [lower, upper] ranges. + self.configs["exact_bounds"] = [min(lower, upper), max(lower, upper)] except ValueError: pass # Optionally handle invalid input @@ -290,6 +361,14 @@ def delta_changed(self, delta): self.configs["delta"] = delta self.update_bounds_preview() + def bounds_lower_changed(self, lower): + self.configs["exact_bounds"][0] = lower + self.update_bounds_preview() + + def bounds_upper_changed(self, upper): + self.configs["exact_bounds"][1] = upper + self.update_bounds_preview() + def set(self): self.update_config() self.apply_config(self.name, self.configs)