diff --git a/doc/OnlineDocs/explanation/solvers/trustregion.rst b/doc/OnlineDocs/explanation/solvers/trustregion.rst index 234d05f2a54..d6ac8ba4e15 100644 --- a/doc/OnlineDocs/explanation/solvers/trustregion.rst +++ b/doc/OnlineDocs/explanation/solvers/trustregion.rst @@ -20,13 +20,14 @@ Pyomo External Functions. This work was conducted as part of the Institute for the Design of Advanced Energy Systems (`IDAES `_) with support through the Simulation-Based Engineering, Crosscutting Research Program within the U.S. -Department of Energy’s Office of Fossil Energy and Carbon Management. +Department of Energy's Office of Fossil Energy and Carbon Management. .. _Eason & Biegler, 2018: https://doi.org/10.1002/aic.16364 .. _Yoshio & Biegler, 2021: https://doi.org/10.1002/aic.17054 +.. _Hameed et al., 2026: https://doi.org/10.1002/aic.70258 Methodology Overview ---------------------- +-------------------- The formulation of the original hybrid problem is: @@ -78,10 +79,35 @@ the iteration has moved in a direction towards an optimal solution. If not true, the step is rejected. If true, the step is accepted and the surrogate model is updated for the next iteration. +Globalization Strategies +^^^^^^^^^^^^^^^^^^^^^^^^ + +The TRF solver supports two globalization strategies to control step acceptance +and trust region updates: the **filter** method (default) and the **funnel** method. +The strategy is selected via the ``globalization_strategy`` keyword argument +(``'filter'`` for filter, ``'funnel'`` for funnel). + +**Filter Method (default)** + +The filter method, used in the original Yoshio & Biegler (2021) implementation, +maintains a filter set of (feasibility, objective) pairs. A new iterate is +accepted if it is not dominated by any entry in the filter. At each iteration, +steps are classified as either f-type (objective-improving) or theta-type +(feasibility-improving), and the trust region radius is updated accordingly. + +**Funnel Method** + +The funnel globalization strategy is an alternative to the filter method, +introduced by [`Hameed et al., 2026`_]. Instead of a discrete filter set, +the funnel maintains a dynamic upper bound on the feasibility measure that +shrinks as the algorithm converges. For full details of the funnel algorithm, +step classification, and acceptance conditions, please refer to +[`Hameed et al., 2026`_]. + When using TRF, please consider citing the above papers. TRF Inputs ------------ +---------- The required inputs to the TRF :py:meth:`solve ` @@ -98,7 +124,7 @@ method is the following: TRF Solver Interface ---------------------- +-------------------- .. note:: The keyword arguments can be updated at solver instantiation or later when the ``solve`` method is called. @@ -108,14 +134,14 @@ TRF Solver Interface :members: solve TRF Usage Example ------------------- +----------------- Two examples can be found in the examples_ subdirectory. One of them is implemented below. .. _examples: https://github.com/Pyomo/pyomo/tree/main/pyomo/contrib/trustregion/examples Step 0: Import Pyomo -^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^ .. doctest:: @@ -123,7 +149,7 @@ Step 0: Import Pyomo >>> import pyomo.environ as pyo Step 1: Define the external function and its gradient -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. doctest:: @@ -135,7 +161,7 @@ Step 1: Define the external function and its gradient ... return [ pyo.cos(a - b), -pyo.cos(a - b) ] Step 2: Create the model -^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^ .. doctest:: @@ -161,8 +187,8 @@ Step 2: Create the model ... return m >>> model = create_model() -Step 3: Solve with TRF -^^^^^^^^^^^^^^^^^^^^^^^ +Step 3: Solve with TRF (Filter, default) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. note:: Reminder from earlier that the ``solve`` method requires the user pass the model and a list of variables @@ -175,11 +201,43 @@ Step 3: Solve with TRF >>> # === Instantiate the TRF solver object === >>> trf_solver = pyo.SolverFactory('trustregion') - >>> # === Solve with TRF === + >>> # === Solve with TRF using the default filter globalization strategy === + >>> result = trf_solver.solve(model, [model.z[0], model.z[1], model.z[2]]) + EXIT: Optimal solution found. + ... + +Step 3 (alternative): Solve with TRF (Funnel) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To use the funnel globalization strategy instead of the filter, set +``globalization_strategy='funnel'``. The funnel-specific parameters can also be +customized as needed: + +.. doctest:: + :skipif: not ipopt_available + + >>> # === Instantiate the TRF solver object with funnel strategy === + >>> trf_solver = pyo.SolverFactory('trustregion', globalization_strategy='funnel') + >>> # === Solve with TRF using the funnel globalization strategy === >>> result = trf_solver.solve(model, [model.z[0], model.z[1], model.z[2]]) EXIT: Optimal solution found. ... +The funnel parameters can also be customized at solve time: + +.. doctest:: + :skipif: not ipopt_available + + >>> result = trf_solver.solve( + ... model, + ... [model.z[0], model.z[1], model.z[2]], + ... globalization_strategy='funnel', + ... funnel_param_kappa_f=0.3, + ... funnel_param_eta=1e-4, + ... ) + EXIT: Optimal solution found. + ... + The :py:meth:`solve ` method returns a clone of the original model which has been run through TRF algorithm, thus leaving the original model intact. diff --git a/pyomo/contrib/trustregion/TRF.py b/pyomo/contrib/trustregion/TRF.py index 46626543d09..699557efa03 100644 --- a/pyomo/contrib/trustregion/TRF.py +++ b/pyomo/contrib/trustregion/TRF.py @@ -26,6 +26,7 @@ document_kwargs_from_configdict, ) from pyomo.contrib.trustregion.filter import Filter, FilterElement +from pyomo.contrib.trustregion.funnel import Funnel from pyomo.contrib.trustregion.interface import TRFInterface from pyomo.contrib.trustregion.util import IterationLogger from pyomo.opt import SolverFactory @@ -78,6 +79,21 @@ def trust_region_method(model, decision_variables, ext_fcn_surrogate_map_rule, c # Initialize trust region radius trust_radius = config.trust_radius + # Initialising funnel method + use_funnel_globalization_strategy = config.globalization_strategy == 'funnel' + if use_funnel_globalization_strategy: + funnel = Funnel( + phi_init=feasibility_k, + f_best_init=obj_val_k, + phi_min=config.funnel_param_phi_min, + kappa_f=config.funnel_param_kappa_f, + kappa_r=config.funnel_param_kappa_r, + alpha=config.funnel_param_alpha, + beta=config.funnel_param_beta, + mu_s=config.funnel_param_mu_s, + eta=config.funnel_param_eta, + ) + iteration = 0 TRFLogger.newIteration( @@ -127,61 +143,121 @@ def trust_region_method(model, decision_variables, ext_fcn_surrogate_map_rule, c iteration, feasibility_k, obj_val_k, trust_radius, step_norm_k ) - # Check filter acceptance - filterElement = FilterElement(obj_val_k, feasibility_k) - if not TRFilter.isAcceptable(filterElement, config.maximum_feasibility): - # Reject the step - TRFLogger.iterrecord.rejected = True - trust_radius = max( - config.minimum_radius, step_norm_k * config.radius_update_param_gamma_c - ) - rebuildSM = False - interface.rejectStep() - # Log iteration information - TRFLogger.logIteration() - if config.verbose: - TRFLogger.printIteration() - continue - - # Switching condition: Eq. (7) in Yoshio/Biegler (2020) - if (obj_val - obj_val_k) >= ( - config.switch_condition_kappa_theta - * pow(feasibility, config.switch_condition_gamma_s) - ) and (feasibility <= config.minimum_feasibility): - # f-type step - TRFLogger.iterrecord.fStep = True - trust_radius = min( - max(step_norm_k * config.radius_update_param_gamma_e, trust_radius), - config.maximum_radius, - ) - else: - # theta-type step - TRFLogger.iterrecord.thetaStep = True - filterElement = FilterElement( - obj_val_k - config.param_filter_gamma_f * feasibility_k, - (1 - config.param_filter_gamma_theta) * feasibility_k, - ) - TRFilter.addToFilter(filterElement) - # Calculate ratio: Eq. (10) in Yoshio/Biegler (2020) - rho_k = ( - feasibility - feasibility_k + config.feasibility_termination - ) / max(feasibility, config.feasibility_termination) - # Ratio tests: Eq. (8) in Yoshio/Biegler (2020) - # If rho_k is between eta_1 and eta_2, trust radius stays same - if (rho_k < config.ratio_test_param_eta_1) or ( - feasibility > config.minimum_feasibility - ): + # If user opts for filter as a globalization mechanism + # Note that filter is also default option + if not use_funnel_globalization_strategy: + # Check filter acceptance + filterElement = FilterElement(obj_val_k, feasibility_k) + if not TRFilter.isAcceptable(filterElement, config.maximum_feasibility): + # Reject the step + TRFLogger.iterrecord.rejected = True trust_radius = max( config.minimum_radius, - (config.radius_update_param_gamma_c * step_norm_k), + step_norm_k * config.radius_update_param_gamma_c, ) - elif rho_k >= config.ratio_test_param_eta_2: + rebuildSM = False + interface.rejectStep() + # Log iteration information + TRFLogger.logIteration() + if config.verbose: + TRFLogger.printIteration() + continue + + # Switching condition: Eq. (7) in Yoshio/Biegler (2020) + if (obj_val - obj_val_k) >= ( + config.switch_condition_kappa_theta + * pow(feasibility, config.switch_condition_gamma_s) + ) and (feasibility <= config.minimum_feasibility): + # f-type step + TRFLogger.iterrecord.fStep = True trust_radius = min( + max(step_norm_k * config.radius_update_param_gamma_e, trust_radius), config.maximum_radius, - max( - trust_radius, (config.radius_update_param_gamma_e * step_norm_k) - ), ) + else: + # theta-type step + TRFLogger.iterrecord.thetaStep = True + filterElement = FilterElement( + obj_val_k - config.param_filter_gamma_f * feasibility_k, + (1 - config.param_filter_gamma_theta) * feasibility_k, + ) + TRFilter.addToFilter(filterElement) + # Calculate ratio: Eq. (10) in Yoshio/Biegler (2020) + rho_k = ( + feasibility - feasibility_k + config.feasibility_termination + ) / max(feasibility, config.feasibility_termination) + # Ratio tests: Eq. (8) in Yoshio/Biegler (2020) + # If rho_k is between eta_1 and eta_2, trust radius stays same + if (rho_k < config.ratio_test_param_eta_1) or ( + feasibility > config.minimum_feasibility + ): + trust_radius = max( + config.minimum_radius, + (config.radius_update_param_gamma_c * step_norm_k), + ) + elif rho_k >= config.ratio_test_param_eta_2: + trust_radius = min( + config.maximum_radius, + max( + trust_radius, + (config.radius_update_param_gamma_e * step_norm_k), + ), + ) + + # If user opts for funnel as a globalization mechanism + else: + # Check funnel acceptance + status = funnel.classify_step( + feasibility, feasibility_k, obj_val, obj_val_k, config.trust_radius + ) + + if status == 'f': + # f-type step + funnel.accept_f(feasibility_k, obj_val_k) + TRFLogger.iterrecord.fStep = True + trust_radius = min( + max(step_norm_k * config.radius_update_param_gamma_e, trust_radius), + config.maximum_radius, + ) + elif status in ('theta', 'theta-relax'): + # theta-type step + funnel.accept_theta(feasibility_k) + TRFLogger.iterrecord.thetaStep = True + # Calculate ratio: Eq. (10) in Yoshio/Biegler (2020) + rho_k = ( + feasibility - feasibility_k + config.feasibility_termination + ) / max(feasibility, config.feasibility_termination) + # Ratio tests: Eq. (8) in Yoshio/Biegler (2020) + # If rho_k is between eta_1 and eta_2, trust radius stays same + if (rho_k < config.ratio_test_param_eta_1) or ( + feasibility > config.minimum_feasibility + ): + trust_radius = max( + config.minimum_radius, + (config.radius_update_param_gamma_c * step_norm_k), + ) + elif rho_k >= config.ratio_test_param_eta_2: + trust_radius = min( + config.maximum_radius, + max( + trust_radius, + (config.radius_update_param_gamma_e * step_norm_k), + ), + ) + elif status == 'reject': + # Reject the step + TRFLogger.iterrecord.rejected = True + trust_radius = max( + config.minimum_radius, + step_norm_k * config.radius_update_param_gamma_c, + ) + rebuildSM = False + interface.rejectStep() + # Log iteration information + TRFLogger.logIteration() + if config.verbose: + TRFLogger.printIteration() + continue TRFLogger.updateIteration(trustRadius=trust_radius) # Accept step and reset for next iteration @@ -388,7 +464,20 @@ def _trf_config(): "Default = 0.2.", ), ) - ### Filter + + # Default globalization strategy + CONFIG.declare( + 'globalization_strategy', + ConfigValue( + default='filter', + domain=In(['filter', 'funnel']), + description="Globalization strategy selection. " + "``'filter'`` = Filter method (default), ``'funnel'`` = Funnel method. " + "Default = ``'filter'``.", + ), + ) + + ### Filter parameters CONFIG.declare( 'maximum_feasibility', ConfigValue( @@ -418,6 +507,84 @@ def _trf_config(): ), ) + ### Funnel parameters + CONFIG.declare( + 'funnel_param_phi_min', + ConfigValue( + default=1e-8, + domain=PositiveFloat, + description="Hard floor on funnel width ``phi_min``. " + "Must satisfy: ``phi_min > 0``. " + "Default = 1e-8.", + ), + ) + + CONFIG.declare( + 'funnel_param_kappa_f', + ConfigValue( + default=0.25, + domain=PositiveFloat, + description="Funnel shrink factor ``kappa_f`` applied after a theta-type step. " + "Must satisfy: ``0 < kappa_f < 1``. " + "Default = 0.25.", + ), + ) + + CONFIG.declare( + 'funnel_param_kappa_r', + ConfigValue( + default=1.05, + domain=PositiveFloat, + description="Funnel relaxation factor ``kappa_r`` for theta-type step. " + "Must satisfy: ``kappa_r > 1``. " + "Default = 1.05.", + ), + ) + + CONFIG.declare( + 'funnel_param_eta', + ConfigValue( + default=0.0001, + domain=PositiveFloat, + description="Armijo coefficient ``eta`` for f-type step sufficient decrease condition. " + "Must satisfy: ``0 < eta < 1``. " + "Default = 1e-4.", + ), + ) + + CONFIG.declare( + 'funnel_param_alpha', + ConfigValue( + default=0.5, + domain=PositiveFloat, + description="Curvature exponent ``alpha`` in funnel boundary condition ``phi^alpha``. " + "Must satisfy: ``0 < alpha < 1``. " + "Default = 0.5.", + ), + ) + + CONFIG.declare( + 'funnel_param_beta', + ConfigValue( + default=0.8, + domain=PositiveFloat, + description="Theta-type shrink factor ``beta``. " + "Must satisfy: ``0 < beta < 1``. " + "Default = 0.8.", + ), + ) + + CONFIG.declare( + 'funnel_param_mu_s', + ConfigValue( + default=0.01, + domain=PositiveFloat, + description="Switching parameter ``mu_s``. " + "Must satisfy: ``mu_s > 0`` (small value, e.g. 1e-2). " + "Default = 0.01.", + ), + ) + return CONFIG diff --git a/pyomo/contrib/trustregion/examples/example2.py b/pyomo/contrib/trustregion/examples/example2.py index a9080d7902a..a7e66f0ead6 100644 --- a/pyomo/contrib/trustregion/examples/example2.py +++ b/pyomo/contrib/trustregion/examples/example2.py @@ -61,7 +61,9 @@ def basis_rule(component, ef_expr): # This problem takes more than the default maximum iterations (50) to solve. # In testing (Mac 10.15/ipopt version 3.12.12 from conda), -# it took 70 iterations. +# it took 70 iterations using default filter globalization strategy. +# If the user opts for funnel strategy, i.e., globalization_strategy='funnel' in +# solver's options, it takes 15 iterations to converge to same result def main(): m = create_model() optTRF = SolverFactory('trustregion', maximum_iterations=100, verbose=True) diff --git a/pyomo/contrib/trustregion/funnel.py b/pyomo/contrib/trustregion/funnel.py new file mode 100644 index 00000000000..013ae318b05 --- /dev/null +++ b/pyomo/contrib/trustregion/funnel.py @@ -0,0 +1,135 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and Engineering +# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this +# software. This software is distributed under the 3-clause BSD License. +# ____________________________________________________________________________________ + +# funnel.py – scalar funnel helper (no Filter list) +# -------------------------------------------------------- +# Implements Hameed et al. (https://doi.org/10.1002/aic.70258) funnel logic +# Funnel is an alternative to filter globalization mechanism +# This addition lets the users to choose Funnel or Filter +# Public API (mirrors simplicity of filterMethod): +# funnel = Funnel(phi_init, f_best_init, +# phi_min, kappa_f, alpha, beta, mu_s, eta) +# +# status = funnel.classify_step(theta_k, theta_plus, +# f_k, f_plus, trust_radius) +# # returns 'f', 'theta', or 'reject' +# +# if status == 'f': +# funnel.accept_f(theta_plus, f_plus) +# elif status == 'theta': +# funnel.accept_theta(theta_plus) +# +# The class stores only two scalars (phi, f_best) and imposes the +# three Kiessling tests: +# • switching f_k - f⁺ ≥ μ_s (θ_k - θ⁺) +# • Armijo f_k - f⁺ ≥ η₁ Δ⁽ᵏ⁾ (Δ supplied by caller) +# • θ‑shrink θ⁺ ≤ β φᵏ +# -------------------------------------------------------- + +from __future__ import annotations + + +class Funnel: + """Scalar funnel tracker for Trust‑Region Funnel (grey‑box). + + Parameters + ---------- + phi_init : initial funnel width φ⁰ (≥ θ⁰) + f_best_init : first feasible objective (usually f⁰) + phi_min : hard floor on φ (>0) + kappa_f : shrink factor after theta‑step (0<κ_f<1) + kappa_r : relax factor for theta (>1) + alpha : curvature exponent (0<α<1) + beta : θ‑type shrink factor (0<β<1) + mu_s : switching parameter δ (small, e.g.1e‑2) + eta : Armijo parameter (0<η<1) + """ + + # ----------------------------------------------------- + def __init__( + self, + phi_init: float, + f_best_init: float, + phi_min: float, + kappa_f: float, + kappa_r: float, + alpha: float, + beta: float, + mu_s: float, + eta: float, + ): + self.phi = max(phi_min, phi_init) + self.f_best = f_best_init + # store parameters + self.phi_min = phi_min + self.kappa_f = kappa_f + self.kappa_r = kappa_r + self.alpha = alpha + self.beta = beta + self.mu_s = mu_s + self.eta = eta + + # ----------------------------------------------------- + # Helper tests (all scalar, no surrogates required) + # ----------------------------------------------------- + def _inside_funnel(self, theta_new: float) -> bool: + return theta_new <= self.phi + + def _switching(self, f_old: float, f_new: float, theta_old: float) -> bool: + return (f_old - f_new) >= self.mu_s * ((theta_old) ** 2) + + def _armijo(self, f_old: float, f_new: float, delta: float) -> bool: + # actual reduction ≥ η₁ Δ (trust‑region radius used as scale) + return (f_old - f_new) >= self.eta * delta + + def _theta_shrink(self, theta_new: float) -> bool: + return theta_new <= self.beta * self.phi + + # ----------------------------------------------------- + # Public classifier + # ----------------------------------------------------- + def classify_step( + self, + theta_old: float, + theta_new: float, + f_old: float, + f_new: float, + delta: float, + ) -> str: + """Return 'f', 'theta', or 'reject' for the trial point.""" + # theta, f and reject steps + if self._inside_funnel(theta_new): + # candidate f‑step → need Armijo + if self._switching(f_old, f_new, theta_old): + return 'f' if self._armijo(f_old, f_new, delta) else 'reject' + # else candidate θ‑step → need θ‑shrink + return 'theta' if self._theta_shrink(theta_new) else 'reject' + + # Outside funnel: allow relaxed theta step + if ( + self._switching(f_old, f_new, theta_old) + and theta_new <= self.kappa_r * self.phi + ): + return 'theta-relax' + + return 'reject' + + # ----------------------------------------------------- + # Updates after acceptance + # ----------------------------------------------------- + def accept_f(self, theta_new: float, f_new: float): + """Call after accepting an f‑type step.""" + if f_new < self.f_best: + self.f_best = f_new + + def accept_theta(self, theta_new: float): + """Call after accepting a θ‑type step.""" + kf = self.kappa_f + # gentle convex combo shrink + self.phi = max(self.phi_min, (1 - kf) * theta_new + kf * self.phi) diff --git a/pyomo/contrib/trustregion/tests/test_TRF.py b/pyomo/contrib/trustregion/tests/test_TRF.py index e704353b5da..2115f40e224 100644 --- a/pyomo/contrib/trustregion/tests/test_TRF.py +++ b/pyomo/contrib/trustregion/tests/test_TRF.py @@ -108,6 +108,50 @@ def test_config_generator(self): self.assertEqual(CONFIG.maximum_feasibility, 50.0) self.assertEqual(CONFIG.param_filter_gamma_theta, 0.01) self.assertEqual(CONFIG.param_filter_gamma_f, 0.01) + self.assertEqual( + CONFIG.globalization_strategy, 'filter' + ) # 0 -> default - filter + self.assertEqual(CONFIG.funnel_param_phi_min, 1e-8) + self.assertEqual(CONFIG.funnel_param_kappa_f, 0.25) + self.assertEqual(CONFIG.funnel_param_kappa_r, 1.05) + self.assertEqual(CONFIG.funnel_param_eta, 0.0001) + self.assertEqual(CONFIG.funnel_param_alpha, 0.5) + self.assertEqual(CONFIG.funnel_param_beta, 0.8) + self.assertEqual(CONFIG.funnel_param_mu_s, 0.01) + + def test_funnel_globalization(self): + self.TRF = SolverFactory( + 'trustregion', globalization_strategy='funnel' + ) # Set Funnel strategy + + log_OUTPUT = StringIO() + print_OUTPUT = StringIO() + sys.stdout = print_OUTPUT + with LoggingIntercept(log_OUTPUT, 'pyomo.contrib.trustregion', logging.INFO): + solve_status = self.try_solve() + sys.stdout = sys.__stdout__ + + # Assertions + self.assertTrue(solve_status) + self.assertIn('Iteration 0', log_OUTPUT.getvalue()) + self.assertIn('EXIT: Optimal solution found.', print_OUTPUT.getvalue()) + + def test_filter_globalization(self): + self.TRF = SolverFactory( + 'trustregion', globalization_strategy='filter' + ) # Set Filter strategy (default) + + log_OUTPUT = StringIO() + print_OUTPUT = StringIO() + sys.stdout = print_OUTPUT + with LoggingIntercept(log_OUTPUT, 'pyomo.contrib.trustregion', logging.INFO): + solve_status = self.try_solve() + sys.stdout = sys.__stdout__ + + # Assertions + self.assertTrue(solve_status) + self.assertIn('Iteration 0', log_OUTPUT.getvalue()) + self.assertIn('EXIT: Optimal solution found.', print_OUTPUT.getvalue()) def test_config_vars(self): # Initialized with 1.0 @@ -236,3 +280,47 @@ def test_solver(self): self.assertEqual(result.name, self.m.name) # The values should not be the same self.assertNotEqual(value(result.obj), value(self.m.obj)) + + +@unittest.skipIf( + not SolverFactory('ipopt').available(False), "The IPOPT solver is not available" +) +class TestTrustRegionMethod(unittest.TestCase): + def setUp(self): + self.m = ConcreteModel() + self.m.x = Var(range(2), domain=Reals, initialize=0.0) + + def blackbox(a): + return (a**3) + (a**2) - a + + def grad_blackbox(args, fixed): + a = args[0] + return [3 * a**2 + 2 * a - 1] + + self.m.bb = ExternalFunction(blackbox, grad_blackbox) + + self.m.obj = Objective(expr=(self.m.x[0]) ** 2 + (self.m.x[1]) ** 2) + self.m.c = Constraint( + expr=self.m.bb(self.m.x[0]) + self.m.x[0] + 1 == self.m.x[1] + ) + + self.ext_fcn_surrogate_map_rule = lambda comp, ef: 0 + self.decision_variables = [self.m.x[0]] + + def test_funnel_step_rejection(self): + """Test Funnel globalization strategy with a problem that invokes step rejection.""" + self.TRF = SolverFactory( + 'trustregion', globalization_strategy='funnel' # Funnel strategy + ) + + # Run the solver + log_OUTPUT = StringIO() + print_OUTPUT = StringIO() + sys.stdout = print_OUTPUT + with LoggingIntercept(log_OUTPUT, 'pyomo.contrib.trustregion', logging.INFO): + solve_status = self.TRF.solve(self.m, self.decision_variables) + sys.stdout = sys.__stdout__ + + # Assertions + self.assertTrue(solve_status) # Solver should succeed + self.assertIn('rejected', log_OUTPUT.getvalue()) diff --git a/pyomo/contrib/trustregion/tests/test_funnel.py b/pyomo/contrib/trustregion/tests/test_funnel.py new file mode 100644 index 00000000000..3af5325383a --- /dev/null +++ b/pyomo/contrib/trustregion/tests/test_funnel.py @@ -0,0 +1,59 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and Engineering +# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this +# software. This software is distributed under the 3-clause BSD License. +# ____________________________________________________________________________________ + +# Testing newly added funnel.py file + +import pyomo.common.unittest as unittest +from pyomo.contrib.trustregion.funnel import Funnel + + +class TestFunnel(unittest.TestCase): + def setUp(self): + self.phi_init = 1.0 + self.f_best_init = 0.0 + self.phi_min = 0.1 + self.kappa_f = 0.5 + self.kappa_r = 1.5 + self.alpha = 0.9 + self.beta = 0.7 + self.mu_s = 0.01 + self.eta = 0.1 + self.funnel = Funnel( + phi_init=self.phi_init, + f_best_init=self.f_best_init, + phi_min=self.phi_min, + kappa_f=self.kappa_f, + kappa_r=self.kappa_r, + alpha=self.alpha, + beta=self.beta, + mu_s=self.mu_s, + eta=self.eta, + ) + + def tearDown(self): + pass + + def test_accept_f(self): + """Test accept_f method.""" + self.funnel.accept_f(theta_new=0.5, f_new=-0.5) # Use f_new < f_best + self.assertEqual(self.funnel.f_best, -0.5) # Expect f_best to update + + def test_classify_step(self): + """Test classify_step method.""" + # Test 'f' condition + status = self.funnel.classify_step( + theta_old=1.0, theta_new=0.5, f_old=1.5, f_new=1.0, delta=0.1 + ) + self.assertEqual(status, 'f') + + # Test 'reject' condition + status = self.funnel.classify_step( + theta_old=1.0, theta_new=2.0, f_old=1.5, f_new=1.0, delta=0.1 + ) + self.assertEqual(status, 'reject')