# ____________________________________________________________________________________
#
# 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.
# ____________________________________________________________________________________
import itertools
import logging
import math
import os
import threading
import enum
from pyomo.common.collections import ComponentMap, ComponentSet
from pyomo.common.config import (
ConfigDict,
ConfigValue,
InEnum,
PositiveInt,
document_class_CONFIG,
)
from pyomo.common.gc_manager import PauseGC
from pyomo.common.modeling import unique_component_name
from pyomo.common.dependencies import dill, dill_available, multiprocessing
from pyomo.common.enums import SolverAPIVersion
from pyomo.core import (
Block,
ConcreteModel,
Constraint,
maximize,
minimize,
NonNegativeIntegers,
Objective,
SortComponents,
Suffix,
value,
Any,
)
from pyomo.core.base import Reference, TransformationFactory
import pyomo.core.expr as EXPR
from pyomo.core.util import target_list
from pyomo.gdp import Disjunct, Disjunction, GDP_Error
from pyomo.gdp.plugins.bigm_mixin import (
_BigM_MixIn,
_convert_M_to_tuple,
_warn_for_unused_bigM_args,
)
from pyomo.gdp.plugins.gdp_to_mip_transformation import GDP_to_MIP_Transformation
from pyomo.gdp.util import _to_dict
from pyomo.opt import SolverFactory, TerminationCondition
from pyomo.repn import generate_standard_repn
from weakref import ref as weakref_ref
logger = logging.getLogger('pyomo.gdp.mbigm')
_trusted_solvers = {
'gurobi',
'cplex',
'cbc',
'glpk',
'scip',
'xpress',
'mosek',
'baron',
'highs',
}
# Whether these are thread-local or at module scope makes no difference
# for 'spawn' or 'forkserver', but it matters if we run single-threaded,
# and possibly for edge cases of 'fork' (although it's not correct to
# use fork() here while having multiple threads anyway, making it moot
# in theory).
_thread_local = threading.local()
_thread_local.solver = None
_thread_local.model = None
_thread_local.config_use_primal_bound = None
_thread_local.in_progress = False
[docs]
def Solver(val):
if isinstance(val, str):
return SolverFactory(val)
if not hasattr(val, 'solve'):
raise ValueError("Expected a string or solver object (with solve() method)")
if not hasattr(val, 'api_version') or val.api_version() is not SolverAPIVersion.V1:
raise ValueError("Solver object should support the V1 solver API version")
return val
[docs]
class ProcessStartMethod(str, enum.Enum):
spawn = 'spawn'
fork = 'fork'
forkserver = 'forkserver'
# Things we call in subprocesses. These can't be member functions, or
# else we'd have to pickle `self`, which is problematic.
def _setup_spawn(model, solver_class, solver_options, use_primal_bound):
# When using 'spawn' or 'forkserver', Python starts in a new
# environment and executes only this file, so we need to manually
# ensure necessary plugins are registered (even if the main process
# has already registered them).
import pyomo.environ
_thread_local.model = dill.loads(model)
_thread_local.solver = dill.loads(solver_class)(options=dill.loads(solver_options))
_thread_local.config_use_primal_bound = use_primal_bound
def _setup_fork():
# model and config_use_primal_bound were already properly set, but
# remake the solver instead of using the passed argument. All these
# processes are copies of the calling thread so the thread-local
# still works.
_thread_local.solver = _thread_local.solver.__class__(
options=_thread_local.solver.options
)
def _calc_M(constraint_name, other_disjunct_name, unsuccessful_message, is_upper):
solver = _thread_local.solver
model = _thread_local.model
scratch = _get_scratch_block(
model.find_component(constraint_name),
model.find_component(other_disjunct_name),
is_upper,
)
results = solver.solve(scratch, tee=False, load_solutions=False, keepfiles=False)
termination_condition = results.solver.termination_condition
if is_upper:
incumbent = results.problem.lower_bound
bound = results.problem.upper_bound
else:
incumbent = results.problem.upper_bound
bound = results.problem.lower_bound
if termination_condition is TerminationCondition.infeasible:
# [2/18/24]: TODO: After the solver rewrite is complete, we will not
# need this check since we can actually determine from the
# termination condition whether or not the solver proved
# infeasibility or just terminated at local infeasiblity. For now,
# while this is not complete, it catches most of the solvers we
# trust, and, unless someone is so pathological as to *rename* an
# untrusted solver using a trusted solver name, it will never do the
# *wrong* thing.
if any(s in solver.name for s in _trusted_solvers):
return (0, True)
else:
# This is a solver that might report
# 'infeasible' for local infeasibility, so we
# can't deactivate with confidence. To be
# conservative, we'll just complain about
# it. Post-solver-rewrite we will want to change
# this so that we check for 'proven_infeasible'
# and then we can abandon this hack
raise GDP_Error(unsuccessful_message)
elif termination_condition is not TerminationCondition.optimal:
raise GDP_Error(unsuccessful_message)
else:
# NOTE: This transformation can be made faster by allowing the
# solver a gap. As long as we have a bound, it's still valid
# (though not as tight).
#
# We should use the dual bound here. The primal bound is
# mathematically incorrect in the presence of numerical error,
# but it's the best a local solver like ipopt can do, so we
# allow it to be used by setting an option.
if not _thread_local.config_use_primal_bound:
M = bound
if not math.isfinite(M):
raise GDP_Error(
"Subproblem solved to optimality, but no finite dual bound was "
"obtained. If you would like to instead use the best obtained "
"solution, set the option use_primal_bound=True. This is "
"necessary when using a local solver such as ipopt, but be "
"aware that interior feasible points for this subproblem do "
"not give valid values for M in general."
)
else:
M = incumbent
if not math.isfinite(M):
# Solved to optimality, but we did not find an incumbent
# objective value. Try again by actually loading the
# solution and evaluating the objective expression.
try:
scratch.solutions.load_from(results)
M = value(scratch.obj)
if not math.isfinite(M):
raise ValueError()
except Exception:
raise GDP_Error(
"`use_primal_bound` is enabled, but could not find a finite "
"objective value after optimal solve."
)
return (M, False)
def _get_scratch_block(constraint, other_disjunct, is_upper):
scratch = ConcreteModel()
if is_upper:
scratch.obj = Objective(expr=constraint.body - constraint.upper, sense=maximize)
else:
scratch.obj = Objective(expr=constraint.body - constraint.lower, sense=minimize)
# Create a Block component (via Reference) that actually points to a
# DisjunctData, as we want the writer to write it as if it were an
# ordinary Block rather than getting any special
# handling. DisjunctData inherits BlockData, so this should be
# valid.
scratch.disjunct_ref = Reference(other_disjunct, ctype=Block)
# Add references to every Var that appears in an active constraint.
# TODO: If the writers don't assume Vars are declared on the Block
# being solved, we won't need this!
seen = set()
for constraint in scratch.component_data_objects(
Constraint, active=True, sort=SortComponents.deterministic, descend_into=Block
):
for var in EXPR.identify_variables(constraint.expr, include_fixed=True):
if id(var) not in seen:
seen.add(id(var))
ref = Reference(var)
scratch.add_component(unique_component_name(scratch, var.name), ref)
return scratch