# ___________________________________________________________________________
#
# Pyomo: Python Optimization Modeling Objects
# Copyright (c) 2008-2025
# 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.
# ___________________________________________________________________________
"""Functions for solving the nonlinear subproblem."""
from pyomo.common.collections import ComponentSet, ComponentMap
from pyomo.common.errors import InfeasibleConstraintException, DeveloperError
from pyomo.contrib import appsi
from pyomo.contrib.appsi.cmodel import cmodel_available
from pyomo.contrib.fbbt.fbbt import fbbt
from pyomo.contrib.gdpopt.solve_discrete_problem import (
distinguish_mip_infeasible_or_unbounded,
)
from pyomo.contrib.gdpopt.util import (
SuppressInfeasibleWarning,
is_feasible,
get_main_elapsed_time,
)
from pyomo.core import Constraint, TransformationFactory, Objective, Block
import pyomo.core.expr as EXPR
from pyomo.opt import SolverFactory, SolverResults
from pyomo.opt import TerminationCondition as tc
[docs]
def process_nonlinear_problem_results(results, model, problem_type, config):
"""Processes the results object returned from the nonlinear solver.
Returns one of TerminationCondition.optimal (for locally optimal or
globally optimal since people use this as a heuristic),
TerminationCondition.feasible (we have a solution with no guarantees),
TerminationCondition.noSolution (we have no solution, with no guarantees
of infeasibility), or TerminationCondition.infeasible.
"""
logger = config.logger
term_cond = results.solver.termination_condition
if any(
term_cond == cond
for cond in (tc.optimal, tc.locallyOptimal, tc.globallyOptimal)
):
# Since we let people use local solvers and settle for the heuristic, we
# just let all these by.
return tc.optimal
elif term_cond == tc.feasible:
return tc.feasible
elif term_cond == tc.infeasible:
logger.debug('%s subproblem was infeasible.' % problem_type)
return tc.infeasible
elif term_cond == tc.maxIterations:
logger.debug(
'%s subproblem failed to converge within iteration limit.' % problem_type
)
if is_feasible(model, config):
logger.debug(
'NLP solution is still feasible. '
'Using potentially suboptimal feasible solution.'
)
return tc.feasible
return False
elif term_cond == tc.internalSolverError:
# Possible that IPOPT had a restoration failure
logger.debug(
"%s solver had an internal failure: %s"
% (problem_type, results.solver.message)
)
return tc.noSolution
elif term_cond == tc.other and "Too few degrees of freedom" in str(
results.solver.message
):
# Possible IPOPT degrees of freedom error
logger.debug(
"Perhaps the subproblem solver has too few degrees of freedom: %s"
% results.solver.message
)
return tc.infeasible
elif term_cond == tc.other:
logger.debug(
"%s solver had a termination condition of 'other': %s"
% (problem_type, results.solver.message)
)
return tc.noSolution
elif term_cond == tc.error:
logger.debug(
"%s solver had a termination condition of 'error': "
"%s" % (problem_type, results.solver.message)
)
return tc.noSolution
elif term_cond == tc.maxTimeLimit:
logger.debug(
"%s subproblem failed to converge within time limit." % problem_type
)
if is_feasible(model, config):
config.logger.debug(
'%s solution is still feasible. '
'Using potentially suboptimal feasible solution.' % problem_type
)
return tc.feasible
return tc.noSolution
elif term_cond == tc.intermediateNonInteger:
config.logger.debug(
"%s solver could not find feasible integer"
" solution: %s" % (problem_type, results.solver.message)
)
return tc.noSolution
elif term_cond == tc.unbounded:
config.logger.debug(
"The NLP subproblem is unbounded, meaning that the GDP is unbounded."
)
return tc.unbounded
else:
# This isn't the user's fault, but we give up--we don't know what's
# going on.
raise DeveloperError(
'GDPopt unable to handle %s subproblem termination '
'condition of %s. Results: %s' % (problem_type, term_cond, results)
)
[docs]
def solve_linear_subproblem(subproblem, config, timing):
results = configure_and_call_solver(
subproblem,
config.mip_solver,
config.mip_solver_args,
'MIP',
timing,
config.time_limit,
)
subprob_terminate_cond = results.solver.termination_condition
if subprob_terminate_cond is tc.optimal:
return tc.optimal
elif subprob_terminate_cond is tc.infeasibleOrUnbounded:
(results, subprob_terminate_cond) = distinguish_mip_infeasible_or_unbounded(
subproblem, config
)
if subprob_terminate_cond is tc.infeasible:
config.logger.debug('MILP subproblem was infeasible.')
return tc.infeasible
elif subprob_terminate_cond is tc.unbounded:
config.logger.debug('MILP subproblem was unbounded.')
return tc.unbounded
else:
raise ValueError(
'GDPopt unable to handle MIP subproblem termination '
'condition of %s. Results: %s' % (subprob_terminate_cond, results)
)
[docs]
def solve_NLP(nlp_model, config, timing):
"""Solve the NLP subproblem."""
config.logger.debug(
'Solving nonlinear subproblem for fixed binaries and logical realizations.'
)
results = configure_and_call_solver(
nlp_model,
config.nlp_solver,
config.nlp_solver_args,
'NLP',
timing,
config.time_limit,
)
return process_nonlinear_problem_results(results, nlp_model, 'NLP', config)
[docs]
def solve_MINLP(util_block, config, timing):
"""Solve the MINLP subproblem."""
config.logger.debug("Solving MINLP subproblem for fixed logical realizations.")
model = util_block.parent_block()
minlp_solver = SolverFactory(config.minlp_solver)
if not minlp_solver.available():
raise RuntimeError("MINLP solver %s is not available." % config.minlp_solver)
results = configure_and_call_solver(
model,
config.minlp_solver,
config.minlp_solver_args,
'MINLP',
timing,
config.time_limit,
)
subprob_termination = process_nonlinear_problem_results(
results, model, 'MINLP', config
)
return subprob_termination
[docs]
def detect_unfixed_discrete_vars(model):
"""Detect unfixed discrete variables in use on the model."""
var_set = ComponentSet()
for constr in model.component_data_objects(
Constraint, active=True, descend_into=True
):
var_set.update(
v
for v in EXPR.identify_variables(constr.body, include_fixed=False)
if not v.is_continuous()
)
for obj in model.component_data_objects(Objective, active=True):
var_set.update(
v
for v in EXPR.identify_variables(obj.expr, include_fixed=False)
if not v.is_continuous()
)
return var_set
[docs]
class preprocess_subproblem(object):
[docs]
def __init__(self, util_block, config):
self.util_block = util_block
self.config = config
self.not_infeas = True
self.unfixed_vars = []
self.original_bounds = ComponentMap()
self.constraints_deactivated = []
self.constraints_modified = {}
def __enter__(self):
"""Applies preprocessing transformations to the model."""
m = self.util_block.parent_block()
# Save bounds so we can restore them
for cons in m.component_data_objects(
Constraint, active=True, descend_into=Block
):
for v in EXPR.identify_variables(cons.expr):
if v not in self.original_bounds.keys():
self.original_bounds[v] = (v.lb, v.ub)
if not v.fixed:
self.unfixed_vars.append(v)
# We could miss if there is a variable that only appears in the
# objective, but its bounds are not going to get changed anyway if
# that's the case.
try:
# First do FBBT
# When #2574 is resolved, we can do the below. For now
# we'll use contrib.fbbt
# if cmodel_available:
# # use the appsi fbbt implementation since we can
# it = appsi.fbbt.IntervalTightener()
# it.config.integer_tol = self.config.integer_tolerance
# it.config.feasibility_tol = self.config.constraint_tolerance
# it.config.max_iter = self.config.max_fbbt_iterations
# it.perform_fbbt(m)
fbbt(
m,
integer_tol=self.config.integer_tolerance,
feasibility_tol=self.config.constraint_tolerance,
max_iter=self.config.max_fbbt_iterations,
)
xfrm = TransformationFactory
# Now that we've tightened bounds, see if any variables are fixed
# because their lb is equal to the ub (within tolerance)
xfrm('contrib.detect_fixed_vars').apply_to(
m, tolerance=self.config.variable_tolerance
)
# Restore the original bounds because the subproblem solver might
# like that better and because, if deactivate_trivial_constraints
# ever gets fancier, this could change what is and is not trivial.
if not self.config.tighten_nlp_var_bounds:
for v, (lb, ub) in self.original_bounds.items():
v.setlb(lb)
v.setub(ub)
# Now, if something got fixed to 0, we might have 0*var terms to
# remove
xfrm('contrib.remove_zero_terms').apply_to(
m, constraints_modified=self.constraints_modified
)
# Last, check if any constraints are now trivial and deactivate them
xfrm('contrib.deactivate_trivial_constraints').apply_to(
m,
tolerance=self.config.constraint_tolerance,
return_trivial=self.constraints_deactivated,
)
except InfeasibleConstraintException as e:
self.config.logger.debug(
"NLP subproblem determined to be infeasible "
"during preprocessing. Message: %s" % e
)
self.not_infeas = False
return self.not_infeas
def __exit__(self, type, value, traceback):
# restore the bounds if we found the problem infeasible or if we didn't
# do it above
if not self.not_infeas or self.config.tighten_nlp_var_bounds:
for v, (lb, ub) in self.original_bounds.items():
v.setlb(lb)
v.setub(ub)
# A bit counter-intuitively (but I assume so that it can propagate those
# bounds elsewhere), fbbt tightens the bounds on the fixed Boolean vars,
# so we restore the bounds here
for disj in self.util_block.disjunct_list:
disj.binary_indicator_var.setlb(0)
disj.binary_indicator_var.setub(1)
for bool_var in self.util_block.non_indicator_boolean_variable_list:
bool_var.get_associated_binary().setlb(0)
bool_var.get_associated_binary().setub(1)
# reactivate constraints
for cons in self.constraints_deactivated:
cons.activate()
for cons, (orig, modified) in self.constraints_modified.items():
cons.set_value(orig)
# unfix variables:
for v in self.unfixed_vars:
v.unfix()
[docs]
def call_appropriate_subproblem_solver(subprob_util_block, solver, config):
timing = solver.timing
subprob = subprob_util_block.parent_block()
config.call_before_subproblem_solve(solver, subprob, subprob_util_block)
# Is the subproblem linear?
if not any(
constr.body.polynomial_degree() not in (1, 0)
for constr in subprob.component_data_objects(Constraint, active=True)
):
subprob_termination = solve_linear_subproblem(subprob, config, timing)
else:
# Does it have any discrete variables, and is that allowed?
unfixed_discrete_vars = detect_unfixed_discrete_vars(subprob)
if config.force_subproblem_nlp and len(unfixed_discrete_vars) > 0:
# this is actually our fault at this point--we should have
# enumerated the discrete solutions if it was possible and the user
# requested.
raise DeveloperError(
"Unfixed discrete variables found on the NLP subproblem."
)
elif len(unfixed_discrete_vars) == 0:
subprob_termination = solve_NLP(subprob, config, timing)
else:
config.logger.debug(
"The following discrete variables are unfixed: %s"
"\nProceeding by solving the subproblem as a MINLP."
% ", ".join([v.name for v in unfixed_discrete_vars])
)
subprob_termination = solve_MINLP(subprob_util_block, config, timing)
# Call the NLP post-solve callback
config.call_after_subproblem_solve(solver, subprob, subprob_util_block)
# if feasible, call the NLP post-feasible callback
if subprob_termination in {tc.optimal, tc.feasible}:
config.call_after_subproblem_feasible(solver, subprob, subprob_util_block)
return subprob_termination
[docs]
def solve_subproblem(subprob_util_block, solver, config):
"""Set up and solve the local MINLP or NLP subproblem."""
if config.subproblem_presolve:
with preprocess_subproblem(subprob_util_block, config) as call_solver:
if call_solver:
return call_appropriate_subproblem_solver(
subprob_util_block, solver, config
)
else:
return tc.infeasible
return call_appropriate_subproblem_solver(subprob_util_block, solver, config)