diff --git a/.coveragerc b/.coveragerc index 5be40925509..412c4c33008 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,6 +3,7 @@ omit = setup.py */tests/* */tmp/* + */minlplib/* # The [run] section must be at the end, as the build harness will add a # "data_file" directive to the end of this file. @@ -17,3 +18,4 @@ source = omit = # github actions creates a cache directory we don't want measured cache/* + */minlplib/* diff --git a/.github/workflows/test_branches.yml b/.github/workflows/test_branches.yml index 611875fb456..e4781b08924 100644 --- a/.github/workflows/test_branches.yml +++ b/.github/workflows/test_branches.yml @@ -21,7 +21,7 @@ defaults: env: PYTHONWARNINGS: ignore::UserWarning PYTHON_CORE_PKGS: wheel - PYPI_ONLY: z3-solver + PYPI_ONLY: z3-solver pybnb PYPY_EXCLUDE: scipy numdifftools seaborn statsmodels CACHE_VER: v221013.1 NEOS_EMAIL: tests@pyomo.org @@ -95,6 +95,14 @@ jobs: PYENV: conda PACKAGES: openmpi mpi4py + - os: ubuntu-latest + python: 3.11 + other: /singletest + category: "-m 'neos or importtest'" + skip_doctest: 1 + TARGET: linux + PYENV: pip + - os: ubuntu-latest python: '3.10' other: /cython @@ -150,6 +158,24 @@ jobs: # path: cache/os # key: pkg-${{env.CACHE_VER}}.0-${{runner.os}} + - name: install GiNaC + if: matrix.other == '/singletest' + run: | + cd .. + curl https://www.ginac.de/CLN/cln-1.3.7.tar.bz2 >cln-1.3.7.tar.bz2 + tar -xvf cln-1.3.7.tar.bz2 + cd cln-1.3.7 + ./configure + make -j 2 + sudo make install + cd .. + curl https://www.ginac.de/ginac-1.8.7.tar.bz2 >ginac-1.8.7.tar.bz2 + tar -xvf ginac-1.8.7.tar.bz2 + cd ginac-1.8.7 + ./configure + make -j 2 + sudo make install + - name: TPL package download cache uses: actions/cache@v4 if: ${{ ! matrix.slim }} @@ -268,6 +294,8 @@ jobs: || echo "WARNING: Gurobi is not available" python -m pip install --cache-dir cache/pip xpress \ || echo "WARNING: Xpress Community Edition is not available" + python -m pip install --cache-dir cache/pip pybnb \ + || echo "WARNING: pybnb is not available" if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else @@ -337,6 +365,8 @@ jobs: echo "*** Install Pyomo dependencies ***" # Note: this will fail the build if any installation fails (or # possibly if it outputs messages to stderr) + CONDA_DEPENDENCIES="$CONDA_DEPENDENCIES metis" + PYPI_DEPENDENCIES="$PYPI_DEPENDENCIES metis" conda install --update-deps -y $CONDA_DEPENDENCIES if test -z "${{matrix.slim}}"; then PYVER=$(echo "py${{matrix.python}}" | sed 's/\.//g') @@ -609,6 +639,14 @@ jobs: echo "" pyomo build-extensions --parallel 2 + - name: Install GiNaC Interface + if: matrix.other == '/singletest' + run: | + export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH + echo "LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH" >> $GITHUB_ENV + cd pyomo/contrib/simplification/ + $PYTHON_EXE build.py --inplace + - name: Report pyomo plugin information run: | echo "$PATH" @@ -616,6 +654,11 @@ jobs: pyomo help --transformations || exit 1 pyomo help --writers || exit 1 + - name: Run Simplification Tests + if: matrix.other == '/singletest' + run: | + pytest -v -m 'simplification' pyomo/contrib/simplification/tests/test_simplification.py --junitxml="TEST-pyomo-simplify.xml" + - name: Run Pyomo tests if: matrix.mpi == 0 run: | diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index 864c19c6d73..a31be4f6979 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -29,7 +29,7 @@ defaults: env: PYTHONWARNINGS: ignore::UserWarning PYTHON_CORE_PKGS: wheel - PYPI_ONLY: z3-solver + PYPI_ONLY: z3-solver pybnb PYPY_EXCLUDE: scipy numdifftools seaborn statsmodels CACHE_VER: v221013.1 NEOS_EMAIL: tests@pyomo.org @@ -187,6 +187,24 @@ jobs: # path: cache/os # key: pkg-${{env.CACHE_VER}}.0-${{runner.os}} + - name: install GiNaC + if: matrix.other == '/singletest' + run: | + cd .. + curl https://www.ginac.de/CLN/cln-1.3.7.tar.bz2 >cln-1.3.7.tar.bz2 + tar -xvf cln-1.3.7.tar.bz2 + cd cln-1.3.7 + ./configure + make -j 2 + sudo make install + cd .. + curl https://www.ginac.de/ginac-1.8.7.tar.bz2 >ginac-1.8.7.tar.bz2 + tar -xvf ginac-1.8.7.tar.bz2 + cd ginac-1.8.7 + ./configure + make -j 2 + sudo make install + - name: TPL package download cache uses: actions/cache@v4 if: ${{ ! matrix.slim }} @@ -305,6 +323,8 @@ jobs: || echo "WARNING: Gurobi is not available" python -m pip install --cache-dir cache/pip xpress \ || echo "WARNING: Xpress Community Edition is not available" + python -m pip install --cache-dir cache/pip pybnb \ + || echo "WARNING: pybnb is not available" if [[ ${{matrix.python}} == pypy* ]]; then echo "skipping wntr for pypy" else @@ -373,6 +393,8 @@ jobs: echo "*** Install Pyomo dependencies ***" # Note: this will fail the build if any installation fails (or # possibly if it outputs messages to stderr) + CONDA_DEPENDENCIES="$CONDA_DEPENDENCIES metis" + PYPI_DEPENDENCIES="$PYPI_DEPENDENCIES metis" conda install --update-deps -q -y $CONDA_DEPENDENCIES if test -z "${{matrix.slim}}"; then PYVER=$(echo "py${{matrix.python}}" | sed 's/\.//g') @@ -646,6 +668,14 @@ jobs: echo "" pyomo build-extensions --parallel 2 + - name: Install GiNaC Interface + if: matrix.other == '/singletest' + run: | + export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH + echo "LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH" >> $GITHUB_ENV + cd pyomo/contrib/simplification/ + $PYTHON_EXE build.py --inplace + - name: Report pyomo plugin information run: | echo "$PATH" @@ -653,6 +683,11 @@ jobs: pyomo help --transformations || exit 1 pyomo help --writers || exit 1 + - name: Run Simplification Tests + if: matrix.other == '/singletest' + run: | + pytest -v -m 'simplification' pyomo/contrib/simplification/tests/test_simplification.py --junitxml="TEST-pyomo-simplify.xml" + - name: Run Pyomo tests if: matrix.mpi == 0 run: | diff --git a/pyomo/contrib/appsi/base.py b/pyomo/contrib/appsi/base.py index 6655ec26524..b0270c98a99 100644 --- a/pyomo/contrib/appsi/base.py +++ b/pyomo/contrib/appsi/base.py @@ -1637,7 +1637,7 @@ def solve( return legacy_results - def available(self, exception_flag=True): + def available(self, exception_flag=False): ans = super(LegacySolverInterface, self).available() if exception_flag and not ans: raise ApplicationError(f'Solver {self.__class__} is not available ({ans}).') diff --git a/pyomo/contrib/appsi/fbbt.py b/pyomo/contrib/appsi/fbbt.py index 8e0c74b00e9..ff0e459b935 100644 --- a/pyomo/contrib/appsi/fbbt.py +++ b/pyomo/contrib/appsi/fbbt.py @@ -147,7 +147,8 @@ def _add_params(self, params: List[ParamData]): cparams = cmodel.create_params(len(params)) for ndx, p in enumerate(params): cp = cparams[ndx] - cp.value = p.value + if p.value is not None: + cp.value = p.value self._param_map[id(p)] = cp if self._symbolic_solver_labels: for ndx, p in enumerate(params): @@ -222,7 +223,8 @@ def _update_variables(self, variables: List[VarData]): def update_params(self): for p_id, p in self._params.items(): cp = self._param_map[p_id] - cp.value = p.value + if p.value is not None: + cp.value = p.value def set_objective(self, obj: ObjectiveData): if self._symbolic_solver_labels: diff --git a/pyomo/contrib/appsi/solvers/highs.py b/pyomo/contrib/appsi/solvers/highs.py index c948444839d..77175e81cc4 100644 --- a/pyomo/contrib/appsi/solvers/highs.py +++ b/pyomo/contrib/appsi/solvers/highs.py @@ -480,6 +480,7 @@ def _remove_constraints(self, cons: List[ConstraintData]): del self._solver_con_to_pyomo_con_map[con_ndx] indices_to_remove.append(con_ndx) self._mutable_helpers.pop(con, None) + indices_to_remove.sort() self._solver_model.deleteRows( len(indices_to_remove), np.array(indices_to_remove) ) diff --git a/pyomo/contrib/coramin/README.md b/pyomo/contrib/coramin/README.md new file mode 100644 index 00000000000..6b347136588 --- /dev/null +++ b/pyomo/contrib/coramin/README.md @@ -0,0 +1,43 @@ +# Coramin + +Coramin is a Pyomo-based Python package that provides tools for +developing tailored algorithms for mixed-integer nonlinear programming +problems (MINLP's). This software includes classes for managing and +refining convex relaxations of nonconvex constraints. These classes +provide methods for updating the relaxation based on new variable +bounds, creating and managing piecewise relaxations (for multi-tree +based algorithms), and adding outer-approximation based cuts for +convex or concave constraints. These classes inherit from Pyomo +Blocks, so they can be easily integrated with Pyomo +models. Additionally, Coramin has functions for automatically +generating convex relaxations of general Pyomo models. Coramin also +has tools for domain reduction, including a parallel implementation +of optimization-based bounds tightening (OBBT) and various OBBT +filtering techniques. + +## Primary Contributors +### [Michael Bynum](https://github.com/michaelbynum) +- Relaxation classes +- OBBT +- OBBT Filtering +- Factorable programming approach to generating relaxations + +### [Carl Laird](https://github.com/carldlaird) +- Parallel OBBT +- McCormick and piecewise McCormick relaxations for bilinear terms +- Relaxations for univariate convex/concave functions + +### [Anya Castillo](https://github.com/anyacastillo) +- Relaxation classes + +### [Francesco Ceccon](https://github.com/fracek) +- Alpha-BB relaxation + +## Relevant Packages + +### [Suspect](https://github.com/cog-imperial/suspect) +Use of Coramin can be improved significantly by also utilizing +Suspect's convexity detection and feasibility-based bounds tightening +features. Future development of Coramin will directly use Suspect in +Coramin's factorable programming approach to generating relaxations. + diff --git a/pyomo/contrib/coramin/__init__.py b/pyomo/contrib/coramin/__init__.py new file mode 100644 index 00000000000..492ee514ab8 --- /dev/null +++ b/pyomo/contrib/coramin/__init__.py @@ -0,0 +1,23 @@ +from pyomo.common.dependencies import numpy, numpy_available, attempt_import +from pyomo.common import unittest + +if not numpy_available: + raise unittest.SkipTest('numpy is not available') + +pybnb, pybnb_available = attempt_import('pybnb') +if not pybnb_available: + raise unittest.SkipTest('pybnb is not available') + +from . import utils +from . import domain_reduction +from . import relaxations +from . import algorithms +from . import third_party +from .utils import ( + RelaxationSide, + FunctionShape, + Effort, + EigenValueBounder, + simplify_expr, + get_objective, +) diff --git a/pyomo/contrib/coramin/algorithms/__init__.py b/pyomo/contrib/coramin/algorithms/__init__.py new file mode 100644 index 00000000000..4703cfef89f --- /dev/null +++ b/pyomo/contrib/coramin/algorithms/__init__.py @@ -0,0 +1,3 @@ +from .ecp_bounder import ECPBounder +from .multitree.multitree import MultiTree +from .bnb.bnb import BnBSolver diff --git a/pyomo/contrib/coramin/algorithms/alg_utils.py b/pyomo/contrib/coramin/algorithms/alg_utils.py new file mode 100644 index 00000000000..d856d690452 --- /dev/null +++ b/pyomo/contrib/coramin/algorithms/alg_utils.py @@ -0,0 +1,89 @@ +import pyomo.environ as pe +from pyomo.repn.standard_repn import generate_standard_repn, StandardRepn +from pyomo.contrib.coramin.relaxations.split_expr import split_expr +from pyomo.core.expr.numeric_expr import LinearExpression +from pyomo.core.base.var import _GeneralVarData +from pyomo.core.base.block import _BlockData +from pyomo.common.collections import ComponentSet +from typing import Tuple, List, Sequence + + +def collect_vars(m: _BlockData) -> Tuple[List[_GeneralVarData], List[_GeneralVarData]]: + binary_vars = ComponentSet() + integer_vars = ComponentSet() + for v in m.vars: + if v.is_binary(): + binary_vars.add(v) + elif v.is_integer(): + integer_vars.add(v) + return list(binary_vars), list(integer_vars) + + +def relax_integers( + binary_vars: Sequence[_GeneralVarData], integer_vars: Sequence[_GeneralVarData] +): + for v in list(binary_vars) + list(integer_vars): + lb, ub = v.bounds + v.domain = pe.Reals + v.setlb(lb) + v.setub(ub) + + +def impose_structure(m): + m.aux_vars = pe.VarList() + + for key, c in list(m.nonlinear.cons.items()): + repn: StandardRepn = generate_standard_repn( + c.body, quadratic=False, compute_values=True + ) + expr_list = split_expr(repn.nonlinear_expr) + if len(expr_list) == 1: + continue + + linear_coefs = list(repn.linear_coefs) + linear_vars = list(repn.linear_vars) + for term in expr_list: + v = m.aux_vars.add() + linear_coefs.append(1) + linear_vars.append(v) + m.vars.append(v) + if c.equality or (c.lb == c.ub and c.lb is not None): + m.nonlinear.cons.add(v == term) + elif c.ub is None: + m.nonlinear.cons.add(v <= term) + elif c.lb is None: + m.nonlinear.cons.add(v >= term) + else: + m.nonlinear.cons.add(v == term) + new_expr = LinearExpression( + constant=repn.constant, linear_coefs=linear_coefs, linear_vars=linear_vars + ) + m.linear.cons.add((c.lb, new_expr, c.ub)) + del m.nonlinear.cons[key] + + if hasattr(m.nonlinear, 'obj'): + obj = m.nonlinear.obj + repn: StandardRepn = generate_standard_repn( + obj.expr, quadratic=False, compute_values=True + ) + expr_list = split_expr(repn.nonlinear_expr) + if len(expr_list) > 1: + linear_coefs = list(repn.linear_coefs) + linear_vars = list(repn.linear_vars) + for term in expr_list: + v = m.aux_vars.add() + linear_coefs.append(1) + linear_vars.append(v) + m.vars.append(v) + if obj.sense == pe.minimize: + m.nonlinear.cons.add(v >= term) + else: + assert obj.sense == pe.maximize + m.nonlinear.cons.add(v <= term) + new_expr = LinearExpression( + constant=repn.constant, + linear_coefs=linear_coefs, + linear_vars=linear_vars, + ) + m.linear.obj = pe.Objective(expr=new_expr, sense=obj.sense) + del m.nonlinear.obj diff --git a/pyomo/contrib/coramin/algorithms/bnb/__init__.py b/pyomo/contrib/coramin/algorithms/bnb/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/algorithms/bnb/bnb.py b/pyomo/contrib/coramin/algorithms/bnb/bnb.py new file mode 100644 index 00000000000..aa13afee396 --- /dev/null +++ b/pyomo/contrib/coramin/algorithms/bnb/bnb.py @@ -0,0 +1,627 @@ +import pybnb +from pyomo.common.timing import HierarchicalTimer +from pyomo.core.base.block import _BlockData +import pyomo.environ as pe +from pyomo.common.errors import InfeasibleConstraintException +from pyomo.common.collections import ComponentSet, ComponentMap +from pyomo.contrib import appsi +from pyomo.common.config import ConfigValue, PositiveFloat +from pyomo.contrib.coramin.clone import clone_shallow_active_flat, get_clone_and_var_map +from pyomo.contrib.coramin.relaxations.auto_relax import _relax_cloned_model +from pyomo.contrib.coramin.relaxations import iterators +from pyomo.contrib.coramin.utils.pyomo_utils import get_objective +from pyomo.core.base.block import _BlockData +from pyomo.contrib.coramin.heuristics.diving import run_diving_heuristic +from pyomo.contrib.coramin.domain_reduction.obbt import perform_obbt +from pyomo.contrib.coramin.cutting_planes.base import CutGenerator +from typing import Tuple, List, Optional +import math +from pyomo.common.dependencies import numpy as np +import logging +from pyomo.contrib.appsi.base import ( + Solver, + MIPSolverConfig, + Results, + TerminationCondition, + SolutionLoader, + SolverFactory, +) +from pyomo.core.staleflag import StaleFlagManager +from pyomo.contrib.coramin.algorithms.alg_utils import ( + impose_structure, + collect_vars, + relax_integers, +) +from pyomo.contrib.coramin.algorithms.cut_gen import find_cut_generators, AlphaBBConfig + + +logger = logging.getLogger(__name__) + + +class BnBConfig(MIPSolverConfig): + def __init__(self): + super().__init__(None, None, False, None, 0) + self.feasibility_tol = self.declare( + "feasibility_tol", ConfigValue(domain=PositiveFloat, default=1e-7) + ) + self.lp_solver = self.declare("lp_solver", ConfigValue()) + self.nlp_solver = self.declare("nlp_solver", ConfigValue()) + self.abs_gap = self.declare("abs_gap", ConfigValue(default=1e-4)) + self.integer_tol = self.declare("integer_tol", ConfigValue(default=1e-4)) + self.node_limit = self.declare("node_limit", ConfigValue(default=1000000000)) + self.mip_gap = 1e-3 + self.num_root_obbt_iters = self.declare( + "num_root_obbt_iters", ConfigValue(default=3) + ) + self.node_obbt_frequency = self.declare( + "node_obbt_frequency", ConfigValue(default=2) + ) + self.alphabb = self.declare("alphabb", AlphaBBConfig()) + + +class NodeState(object): + def __init__( + self, + lbs: np.ndarray, + ubs: np.ndarray, + parent: Optional[pybnb.Node], + sol: Optional[np.ndarray] = None, + obj: Optional[float] = None, + ) -> None: + self.lbs: np.ndarray = lbs + self.ubs: np.ndarray = ubs + self.parent: Optional[pybnb.Node] = parent + self.sol: Optional[np.ndarray] = sol + self.obj: Optional[float] = obj + self.valid_cut_indices: List[int] = list() + self.active_cut_indices: List[int] = list() + + +def _fix_vars_with_close_bounds(varlist, tol=1e-12): + for v in varlist: + if v.is_fixed(): + v.setlb(v.value) + v.setub(v.value) + lb, ub = v.bounds + if lb is None or ub is None: + continue + if abs(ub - lb) <= tol * min(abs(lb), abs(ub)) + tol: + v.fix(0.5 * (lb + ub)) + + +class _BnB(pybnb.Problem): + def __init__(self, model: _BlockData, config: BnBConfig, feasible_objective=None): + # remove all parameters, fixed variables, etc. + nlp, relaxation = clone_shallow_active_flat(model, 2) + self.nlp: _BlockData = nlp + self.relaxation: _BlockData = relaxation + self.config = config + self.config.lp_solver.config.load_solution = False + self.relaxation_solution = None + + self.obj = obj = get_objective(nlp) + if obj.sense == pe.minimize: + self._sense = pybnb.minimize + else: + self._sense = pybnb.maximize + + # perform fbbt before constructing relaxations in case + # we can identify things like x**3 is convex because + # x >= 0 + self.interval_tightener = it = appsi.fbbt.IntervalTightener() + it.config.deactivate_satisfied_constraints = False + it.config.feasibility_tol = config.feasibility_tol + if feasible_objective is not None: + if obj.sense == pe.minimize: + relaxation.obj_ineq = pe.Constraint(expr=obj.expr <= feasible_objective) + else: + relaxation.obj_ineq = pe.Constraint(expr=obj.expr >= feasible_objective) + it.perform_fbbt(relaxation) + if feasible_objective is not None: + del relaxation.obj_ineq + _fix_vars_with_close_bounds(relaxation.vars) + + impose_structure(relaxation) + self.cut_generators: List[CutGenerator] = find_cut_generators( + relaxation, self.config.alphabb + ) + _relax_cloned_model(relaxation) + relaxation.cuts = pe.ConstraintList() + self.relaxation_objects = list() + for r in iterators.relaxation_data_objects( + relaxation, descend_into=True, active=True + ): + self.relaxation_objects.append(r) + r.rebuild(build_nonlinear_constraint=True) + self.interval_tightener.perform_fbbt(self.relaxation) + + binary_vars, integer_vars = collect_vars(nlp) + relax_integers(binary_vars, integer_vars) + self.binary_vars = binary_vars + self.integer_vars = integer_vars + self.bin_and_int_vars = list(binary_vars) + list(integer_vars) + int_var_set = ComponentSet(self.bin_and_int_vars) + + self.rhs_vars = list() + for r in self.relaxation_objects: + self.rhs_vars.extend(i for i in r.get_rhs_vars() if not i.is_fixed()) + self.rhs_vars = list(ComponentSet(self.rhs_vars) - int_var_set) + + var_set = ComponentSet(self.binary_vars + self.integer_vars + self.rhs_vars) + other_vars = ComponentSet(i for i in relaxation.vars if i not in var_set) + self.other_vars = other_vars = list(other_vars) + + self.all_branching_vars = ( + list(binary_vars) + list(integer_vars) + list(self.rhs_vars) + ) + self.all_vars = self.all_branching_vars + self.other_vars + self.var_to_ndx_map = ComponentMap( + (v, ndx) for ndx, v in enumerate(self.all_vars) + ) + + self.current_node: Optional[pybnb.Node] = None + self.feasible_objective = feasible_objective + + if self._sense == pybnb.minimize: + if self.feasible_objective is None: + feasible_objective = math.inf + else: + feasible_objective = self.feasible_objective + feasible_objective += abs(feasible_objective) * 1e-3 + 1e-3 + else: + if self.feasible_objective is None: + feasible_objective = -math.inf + else: + feasible_objective = self.feasible_objective + feasible_objective -= abs(feasible_objective) * 1e-3 + 1e-3 + + for _ in range(self.config.num_root_obbt_iters): + for r in self.relaxation_objects: + r.rebuild() + perform_obbt( + relaxation, + solver=self.config.lp_solver, + varlist=list(self.rhs_vars), + objective_bound=feasible_objective, + parallel=False, + ) + for r in self.relaxation_objects: + r.rebuild(build_nonlinear_constraint=True) + self.interval_tightener.perform_fbbt(self.relaxation) + for r in self.relaxation_objects: + r.rebuild() + + def sense(self): + return self._sense + + def bound(self): + # Do FBBT + for r in self.relaxation_objects: + r.rebuild(build_nonlinear_constraint=True) + for v in self.binary_vars: + v.domain = pe.Binary + for v in self.integer_vars: + v.domain = pe.Integers + try: + self.interval_tightener.perform_fbbt(self.relaxation) + except InfeasibleConstraintException: + return self.infeasible_objective() + finally: + for r in self.relaxation_objects: + r.rebuild() + for v in self.bin_and_int_vars: + v.domain = pe.Reals + + # solve the relaxation + res = self.config.lp_solver.solve(self.relaxation) + if res.termination_condition == appsi.base.TerminationCondition.infeasible: + return self.infeasible_objective() + if res.termination_condition != appsi.base.TerminationCondition.optimal: + raise RuntimeError( + f"Cannot handle termination condition {res.termination_condition} when solving relaxation" + ) + res.solution_loader.load_vars() + + # add OA cuts for convex constraints + while True: + added_cuts = False + for r in self.relaxation_objects: + new_con = r.add_cut( + keep_cut=True, + check_violation=True, + feasibility_tol=self.config.feasibility_tol, + ) + if new_con is not None: + added_cuts = True + if added_cuts: + res = self.config.lp_solver.solve(self.relaxation) + if ( + res.termination_condition + == appsi.base.TerminationCondition.infeasible + ): + return self.infeasible_objective() + if res.termination_condition != appsi.base.TerminationCondition.optimal: + raise RuntimeError( + f"Cannot handle termination condition {res.termination_condition} when solving relaxation" + ) + res.solution_loader.load_vars() + else: + break + + # add all other types of cuts + while True: + added_cuts = False + for cg in self.cut_generators: + cut_expr = cg.generate(self.current_node) + if cut_expr is not None: + new_con = self.relaxation.cuts.add(cut_expr) + new_con_index = new_con.index() + self.current_node.state.valid_cut_indices.append(new_con_index) + self.current_node.state.active_cut_indices.append(new_con_index) + if added_cuts: + res = self.config.lp_solver.solve(self.relaxation) + if ( + res.termination_condition + == appsi.base.TerminationCondition.infeasible + ): + return self.infeasible_objective() + if res.termination_condition != appsi.base.TerminationCondition.optimal: + raise RuntimeError( + f"Cannot handle termination condition {res.termination_condition} when solving relaxation" + ) + res.solution_loader.load_vars() + else: + break + + # save the variable values to reload later + self.relaxation_solution = res.solution_loader.get_primals() + + # if the solution is feasible, we are done + is_feasible = True + for v in self.bin_and_int_vars: + err = abs(v.value - round(v.value)) + if err > self.config.integer_tol: + is_feasible = False + break + if is_feasible: + for r in self.relaxation_objects: + err = r.get_deviation() + if err > self.config.feasibility_tol: + is_feasible = False + break + if is_feasible: + sol = np.array([v.value for v in self.all_vars], dtype=float) + self.current_node.state.sol = sol + self.current_node.state.obj = res.best_feasible_objective + ret = res.best_feasible_objective + if self.sense() == pybnb.minimize: + ret -= min(abs(ret) * 0.001 * self.config.mip_gap, 0.01) + else: + ret += min(abs(ret) * 0.001 * self.config.mip_gap, 0.01) + return ret + + # maybe do OBBT + if ( + self.current_node.tree_depth % self.config.node_obbt_frequency == 0 + and self.current_node.tree_depth != 0 + ): + should_obbt = True + if self._sense == pybnb.minimize: + if self.feasible_objective is None: + feasible_objective = math.inf + else: + feasible_objective = self.feasible_objective + feasible_objective += abs(feasible_objective) * 1e-3 + 1e-3 + if ( + feasible_objective - res.best_objective_bound + <= self.config.mip_gap * feasible_objective + self.config.abs_gap + ): + should_obbt = False + else: + if self.feasible_objective is None: + feasible_objective = -math.inf + else: + feasible_objective = self.feasible_objective + feasible_objective -= abs(feasible_objective) * 1e-3 + 1e-3 + if ( + res.best_objective_bound - feasible_objective + <= self.config.mip_gap * feasible_objective + self.config.abs_gap + ): + should_obbt = False + if not math.isfinite(feasible_objective): + feasible_objective = None + if should_obbt: + perform_obbt( + self.relaxation, + solver=self.config.lp_solver, + varlist=list(self.rhs_vars), + objective_bound=feasible_objective, + parallel=False, + ) + for r in self.relaxation_objects: + r.rebuild() + res = self.config.lp_solver.solve(self.relaxation) + if ( + res.termination_condition + == appsi.base.TerminationCondition.infeasible + ): + return self.infeasible_objective() + res.solution_loader.load_vars() + self.relaxation_solution = res.solution_loader.get_primals() + + ret = res.best_objective_bound + if self.sense() == pybnb.minimize: + ret -= min(abs(ret) * 0.001 * self.config.mip_gap, 0.01) + else: + ret += min(abs(ret) * 0.001 * self.config.mip_gap, 0.01) + return ret + + def objective(self): + if self.current_node.state.sol is not None: + return self.current_node.state.obj + if self.current_node.tree_depth % 10 != 0: + return self.infeasible_objective() + unfixed_vars = [v for v in self.bin_and_int_vars if not v.is_fixed()] + for v in unfixed_vars: + val = round(v.value) + if val < v.lb: + val += 1 + if val > v.ub: + val -= 1 + assert v.lb <= val <= v.ub + v.fix(val) + try: + res = self.config.nlp_solver.solve( + self.nlp, load_solutions=False, skip_trivial_constraints=True, tee=False + ) + success = True + except: + success = False + if not success or not pe.check_optimal_termination(res): + ret = self.infeasible_objective() + else: + self.nlp.solutions.load_from(res) + ret = pe.value(self.obj.expr) + if self.sense == pybnb.minimize: + if self.feasible_objective is None or ret < self.feasible_objective: + self.feasible_objective = ret + else: + if self.feasible_objective is None or ret > self.feasible_objective: + self.feasible_objective = ret + sol = np.array([v.value for v in self.all_vars], dtype=float) + self.current_node.state.sol = sol + self.current_node.state.obj = ret + for v in unfixed_vars: + v.unfix() + if self.sense() == pybnb.minimize: + ret += min(abs(ret) * 0.001 * self.config.mip_gap, 0.01) + else: + ret -= min(abs(ret) * 0.001 * self.config.mip_gap, 0.01) + return ret + + def get_state(self) -> NodeState: + xl = list() + xu = list() + + for v in self.bin_and_int_vars: + xl.append(math.ceil(v.lb - self.config.integer_tol)) + xu.append(math.floor(v.ub + self.config.integer_tol)) + + for v in self.rhs_vars + self.other_vars: + lb, ub = v.bounds + if lb is None: + xl.append(-math.inf) + else: + xl.append(v.lb) + if xu is None: + xu.append(math.inf) + else: + xu.append(v.ub) + + xl = np.array(xl, dtype=float) + xu = np.array(xu, dtype=float) + + return NodeState(xl, xu, None, None, None) + + def save_state(self, node): + node.state = self.get_state() + + def load_state(self, node): + self.current_node = node + xl = node.state.lbs + xu = node.state.ubs + + xl = [float(i) for i in xl] + xu = [float(i) for i in xu] + + for v, lb, ub in zip(self.all_vars, xl, xu): + if math.isfinite(lb): + v.setlb(lb) + else: + v.setlb(None) + if math.isfinite(ub): + v.setub(ub) + else: + v.setub(None) + + v.unfix() + + _fix_vars_with_close_bounds(self.all_vars) + + for r in self.relaxation_objects: + r.rebuild() + + for c in self.relaxation.cuts.values(): + c.deactivate() + + for ndx in node.state.active_cut_indices: + self.relaxation.cuts[ndx].activate() + + def branch(self): + ns = self.get_state() + xl = ns.lbs + xu = ns.ubs + + # reload the solution to the relaxation to make sure branching happens correctly + for v, val in self.relaxation_solution.items(): + v.set_value(val, skip_validation=True) + + int_var_to_branch_on = None + max_viol = 0 + for v in self.bin_and_int_vars: + err = abs(v.value - round(v.value)) + if err > max_viol and err > self.config.integer_tol: + int_var_to_branch_on = v + max_viol = err + + max_viol = 0 + nl_var_to_branch_on = None + for r in self.relaxation_objects: + err = r.get_deviation() + if err > max_viol and err > self.config.feasibility_tol: + nl_var_to_branch_on = r.get_rhs_vars()[0] + max_viol = err + + if self.current_node.tree_depth % 2 == 0: + if int_var_to_branch_on is not None: + var_to_branch_on = int_var_to_branch_on + else: + var_to_branch_on = nl_var_to_branch_on + else: + if nl_var_to_branch_on is not None: + var_to_branch_on = nl_var_to_branch_on + else: + var_to_branch_on = int_var_to_branch_on + + if var_to_branch_on is None: + # the relaxation was feasible + # no nodes in this part of the tree need explored + return [] + + xl1 = xl.copy() + xu1 = xu.copy() + xl2 = xl.copy() + xu2 = xu.copy() + child1 = pybnb.Node() + child2 = pybnb.Node() + + ndx_to_branch_on = self.var_to_ndx_map[var_to_branch_on] + new_lb = new_ub = var_to_branch_on.value + if ndx_to_branch_on < len(self.bin_and_int_vars): + new_ub = math.floor(new_ub) + new_lb = math.ceil(new_lb) + xu1[ndx_to_branch_on] = new_ub + xl2[ndx_to_branch_on] = new_lb + + child1.state = NodeState(xl1, xu1, self.current_node, None, None) + child2.state = NodeState(xl2, xu2, self.current_node, None, None) + + child1.state.valid_cut_indices = list(self.current_node.state.valid_cut_indices) + child2.state.valid_cut_indices = list(self.current_node.state.valid_cut_indices) + child1.state.active_cut_indices = list( + self.current_node.state.active_cut_indices + ) + child2.state.active_cut_indices = list( + self.current_node.state.active_cut_indices + ) + + yield child1 + yield child2 + + +def solve_with_bnb(model: _BlockData, config: BnBConfig): + # we don't want to modify the original model + model, orig_var_map = get_clone_and_var_map(model) + diving_obj, diving_sol = run_diving_heuristic( + model, config.feasibility_tol, config.integer_tol, node_limit=100 + ) + prob = _BnB(model, config, feasible_objective=diving_obj) + res: pybnb.SolverResults = pybnb.solve( + prob, + best_objective=diving_obj, + queue_strategy=pybnb.QueueStrategy.bound, + absolute_gap=config.abs_gap, + relative_gap=config.mip_gap, + comparison_tolerance=1e-4, + comm=None, + time_limit=config.time_limit, + node_limit=config.node_limit, + # log=logger, + ) + ret = Results() + ret.best_feasible_objective = res.objective + ret.best_objective_bound = res.bound + ss = pybnb.SolutionStatus + tc = pybnb.TerminationCondition + if res.solution_status == ss.optimal: + ret.termination_condition = TerminationCondition.optimal + elif res.solution_status == ss.infeasible: + ret.termination_condition = TerminationCondition.infeasible + elif res.solution_status == ss.unbounded: + ret.termination_condition = TerminationCondition.unbounded + elif res.termination_condition == tc.time_limit: + ret.termination_condition = TerminationCondition.maxTimeLimit + elif res.termination_condition == tc.objective_limit: + ret.termination_condition = TerminationCondition.objectiveLimit + elif res.termination_condition == tc.node_limit: + ret.termination_condition = TerminationCondition.maxIterations + elif res.termination_condition == tc.interrupted: + ret.termination_condition = TerminationCondition.interrupted + else: + ret.termination_condition = TerminationCondition.unknown + best_node = res.best_node + if best_node is None: + if diving_obj is not None: + ret.solution_loader = SolutionLoader( + primals={ + id(orig_var_map[v]): (orig_var_map[v], val) + for v, val in diving_sol.items() + }, + duals=None, + slacks=None, + reduced_costs=None, + ) + else: + vals = best_node.state.sol + primals = dict() + orig_vars = ComponentSet(prob.nlp.vars) + for v, val in zip(prob.all_vars, vals): + if v in orig_vars: + ov = orig_var_map[v] + primals[id(ov)] = (ov, val) + ret.solution_loader = SolutionLoader( + primals=primals, duals=None, slacks=None, reduced_costs=None + ) + return ret + + +class BnBSolver(Solver): + def __init__(self) -> None: + super().__init__() + self._config = BnBConfig() + + def available(self): + return self.Availability.FullLicense + + def version(self) -> Tuple: + return (1, 0, 0) + + @property + def config(self): + return self._config + + @property + def symbol_map(self): + raise NotImplementedError('do this') + + def solve(self, model: _BlockData, timer: HierarchicalTimer = None) -> Results: + StaleFlagManager.mark_all_as_stale() + res = solve_with_bnb(model, self.config) + if self.config.load_solution: + res.solution_loader.load_vars() + return res + + +SolverFactory.register(name="coramin_bnb", doc="Coramin Branch and Bound Solver")( + BnBSolver +) diff --git a/pyomo/contrib/coramin/algorithms/bnb/tests/__init__.py b/pyomo/contrib/coramin/algorithms/bnb/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/algorithms/bnb/tests/test_bnb.py b/pyomo/contrib/coramin/algorithms/bnb/tests/test_bnb.py new file mode 100644 index 00000000000..033cc9ae991 --- /dev/null +++ b/pyomo/contrib/coramin/algorithms/bnb/tests/test_bnb.py @@ -0,0 +1,312 @@ +import math + +from pyomo.contrib import coramin +from pyomo.contrib.coramin.third_party.minlplib_tools import ( + get_minlplib, + parse_osil_file, +) +from pyomo.common import unittest +from pyomo.contrib import appsi +import os +import logging +import math +from pyomo.common import download +import pyomo.environ as pe +from pyomo.core.base.block import _BlockData +import shutil + + +highs_available = appsi.solvers.Highs().available() +ipopt_available = pe.SolverFactory('ipopt').available() + + +def _get_sol(pname): + start_x1_set = {'batch0812', 'chem'} + current_dir = os.getcwd() + target_fname = os.path.join(current_dir, f'{pname}.sol') + downloader = download.FileDownloader() + downloader.set_destination_filename(target_fname) + downloader.get_binary_file(f'http://www.minlplib.org/sol/{pname}.p1.sol') + res = dict() + f = open(target_fname, 'r') + for line in f.readlines(): + l = line.split() + vname = l[0] + vval = float(l[1]) + if vname == 'objvar': + continue + assert vname.startswith('x') or vname.startswith('b') + res[vname] = vval + f.close() + return res + + +class Helper(unittest.TestCase): + def _check_relative_diff(self, expected, got, abs_tol=1e-3, rel_tol=1e-3): + abs_diff = abs(expected - got) + if expected == 0: + rel_diff = math.inf + else: + rel_diff = abs_diff / abs(expected) + success = abs_diff <= abs_tol or rel_diff <= rel_tol + self.assertTrue( + success, + msg=f'\n expected: {expected}\n got: {got}\n abs diff: {abs_diff}\n rel diff: {rel_diff}', + ) + + +@unittest.skipUnless(ipopt_available and highs_available, 'need both ipopt and highs') +class TestBnBWithMINLPLib(Helper): + @classmethod + def setUpClass(self) -> None: + self.test_problems = { + 'batch0812': 2687026.784, + 'ball_mk3_10': None, + 'ball_mk2_10': 0, + 'syn05m': 837.73240090, + 'autocorr_bern20-03': -72, + 'chem': -47.70651483, + 'alkyl': -1.76499965, + } + self.primal_sol = dict() + self.primal_sol['batch0812'] = _get_sol('batch0812') + self.primal_sol['alkyl'] = _get_sol('alkyl') + self.primal_sol['ball_mk2_10'] = _get_sol('ball_mk2_10') + self.primal_sol['syn05m'] = _get_sol('syn05m') + self.primal_sol['autocorr_bern20-03'] = _get_sol('autocorr_bern20-03') + self.primal_sol['chem'] = _get_sol('chem') + for pname in self.test_problems.keys(): + get_minlplib(problem_name=pname, format='osil') + self.opt = coramin.algorithms.BnBSolver() + self.opt.config.lp_solver = appsi.solvers.Highs() + self.opt.config.nlp_solver = pe.SolverFactory('ipopt') + + @classmethod + def tearDownClass(self) -> None: + current_dir = os.getcwd() + for pname in self.test_problems.keys(): + os.remove(os.path.join(current_dir, 'minlplib', 'osil', f'{pname}.osil')) + shutil.rmtree(os.path.join(current_dir, 'minlplib', 'osil')) + os.rmdir(os.path.join(current_dir, 'minlplib')) + for pname in self.primal_sol.keys(): + os.remove(os.path.join(current_dir, f'{pname}.sol')) + + def get_model(self, pname): + current_dir = os.getcwd() + fname = os.path.join(current_dir, 'minlplib', 'osil', f'{pname}.osil') + m = parse_osil_file(fname) + return m + + def _check_primal_sol(self, pname, m: _BlockData, res: appsi.base.Results): + expected_by_str = self.primal_sol[pname] + expected_by_var = pe.ComponentMap() + for vname, vval in expected_by_str.items(): + v = m.vars[vname] + expected_by_var[v] = vval + got = res.solution_loader.get_primals() + for v, val in expected_by_var.items(): + self._check_relative_diff(val, got[v]) + got = res.solution_loader.get_primals(vars_to_load=list(expected_by_var.keys())) + for v, val in expected_by_var.items(): + self._check_relative_diff(val, got[v]) + + def optimal_helper(self, pname, check_primal_sol=True): + m = self.get_model(pname) + res = self.opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self._check_relative_diff( + self.test_problems[pname], + res.best_feasible_objective, + abs_tol=self.opt.config.abs_gap, + rel_tol=self.opt.config.mip_gap, + ) + self._check_relative_diff( + self.test_problems[pname], + res.best_objective_bound, + abs_tol=self.opt.config.abs_gap, + rel_tol=self.opt.config.mip_gap, + ) + if check_primal_sol: + self._check_primal_sol(pname, m, res) + + def infeasible_helper(self, pname): + m = self.get_model(pname) + self.opt.config.load_solution = False + res = self.opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.infeasible + ) + self.opt.config.load_solution = True + + def time_limit_helper(self, pname): + orig_time_limit = self.opt.config.time_limit + self.opt.config.load_solution = False + for new_limit in [0, 0.2]: + self.opt.config.time_limit = new_limit + m = self.get_model(pname) + res = self.opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.maxTimeLimit + ) + self.opt.config.load_solution = True + self.opt.config.time_limit = orig_time_limit + + def test_batch0812(self): + self.optimal_helper('batch0812') + + def test_ball_mk2_10(self): + self.optimal_helper('ball_mk2_10') + + def test_alkyl(self): + self.optimal_helper('alkyl') + + def test_syn05m(self): + self.optimal_helper('syn05m') + + def test_autocorr_bern20_03(self): + self.optimal_helper('autocorr_bern20-03', check_primal_sol=False) + + def test_chem(self): + self.optimal_helper('chem') + + def test_time_limit(self): + self.time_limit_helper('chem') + + def test_ball_mk3_10(self): + self.infeasible_helper('ball_mk3_10') + + def test_available(self): + avail = self.opt.available() + assert avail in appsi.base.Solver.Availability + + +@unittest.skipUnless(ipopt_available and highs_available, 'need both ipopt and highs') +class TestBnB(Helper): + def test_convex_overestimator(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, 1)) + m.y = pe.Var() + m.obj = pe.Objective(expr=(m.x + 1) ** 2 - 0.2 * m.y) + m.c = pe.Constraint(expr=m.y <= m.x**2) + opt = coramin.algorithms.BnBSolver() + opt.config.lp_solver = appsi.solvers.Highs() + opt.config.nlp_solver = pe.SolverFactory('ipopt') + opt.config.mip_gap = 1e-6 + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self._check_relative_diff( + -0.25, + res.best_feasible_objective, + abs_tol=opt.config.abs_gap, + rel_tol=opt.config.mip_gap, + ) + self._check_relative_diff( + -0.25, + res.best_objective_bound, + abs_tol=opt.config.abs_gap, + rel_tol=opt.config.mip_gap, + ) + self._check_relative_diff(-1.250953, m.x.value, 1e-2, 1e-2) + self._check_relative_diff(1.5648825, m.y.value, 1e-2, 1e-2) + + def test_max_iter(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, 1)) + m.y = pe.Var() + m.obj = pe.Objective(expr=(m.x + 1) ** 2 - 0.2 * m.y) + m.c = pe.Constraint(expr=m.y <= m.x**2) + opt = coramin.algorithms.BnBSolver() + opt.config.lp_solver = appsi.solvers.Highs() + opt.config.nlp_solver = pe.SolverFactory('ipopt') + opt.config.node_limit = 1 + opt.config.load_solution = False + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.maxIterations + ) + + def test_nlp_infeas_fbbt(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, 1), domain=pe.Integers) + m.y = pe.Var(domain=pe.Integers, bounds=(-1000, 1000)) + m.obj = pe.Objective(expr=(m.x + 1) ** 2 - 0.2 * m.y) + m.c1 = pe.Constraint(expr=m.y <= (m.x - 0.5) ** 2 - 0.5) + m.c2 = pe.Constraint(expr=m.y >= -((m.x + 2) ** 2) + 4) + m.c3 = pe.Constraint(expr=m.y <= 2 * m.x + 7) + m.c4 = pe.Constraint(expr=m.y >= m.x) + opt = coramin.algorithms.BnBSolver() + opt.config.lp_solver = appsi.solvers.Highs() + opt.config.nlp_solver = pe.SolverFactory('ipopt') + opt.config.load_solution = False + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.infeasible + ) + + def test_all_vars_fixed_in_nlp(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, 1)) + m.y = pe.Var(domain=pe.Integers, bounds=(-10000, 10000)) + m.z = pe.Var() + m.obj = pe.Objective(expr=m.z - 0.2 * m.y) + m.c1 = pe.Constraint(expr=m.y == (m.x - 0.5) ** 2 - 0.5) + m.c2 = pe.Constraint(expr=m.z == (m.x + 1) ** 2) + opt = coramin.algorithms.BnBSolver() + opt.config.lp_solver = appsi.solvers.Highs() + opt.config.nlp_solver = pe.SolverFactory('ipopt') + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self._check_relative_diff(-0.462486082, res.best_feasible_objective) + self._check_relative_diff(-0.462486082, res.best_objective_bound) + self._check_relative_diff(-1.37082869, m.x.value) + self._check_relative_diff(3, m.y.value) + self._check_relative_diff(0.137513918, m.z.value) + + def test_linear_problem(self): + m = pe.ConcreteModel() + m.x = pe.Var() + m.y = pe.Var() + m.obj = pe.Objective(expr=m.y) + m.c1 = pe.Constraint(expr=m.y >= m.x + 1) + m.c2 = pe.Constraint(expr=m.y >= -m.x + 1) + opt = coramin.algorithms.BnBSolver() + opt.config.lp_solver = appsi.solvers.Highs() + opt.config.nlp_solver = pe.SolverFactory('ipopt') + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self.assertAlmostEqual(res.best_feasible_objective, 1) + self.assertAlmostEqual(res.best_objective_bound, 1, 5) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + + def test_stale_fixed_vars(self): + m = pe.ConcreteModel() + m.x = pe.Var() + m.y = pe.Var() + m.z = pe.Var(domain=pe.Binary) + m.w = pe.Var() + m.obj = pe.Objective(expr=m.y) + m.c1 = pe.Constraint(expr=m.y >= m.x + 1) + m.c2 = pe.Constraint(expr=m.y >= -m.x + 1) + m.c3 = pe.Constraint(expr=m.w == 2) + opt = coramin.algorithms.BnBSolver() + opt.config.lp_solver = appsi.solvers.Highs() + opt.config.nlp_solver = pe.SolverFactory('ipopt') + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self.assertAlmostEqual(res.best_feasible_objective, 1) + self.assertAlmostEqual(res.best_objective_bound, 1, 5) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + self.assertAlmostEqual(m.w.value, 2) + self.assertIsNone(m.z.value) diff --git a/pyomo/contrib/coramin/algorithms/cut_gen.py b/pyomo/contrib/coramin/algorithms/cut_gen.py new file mode 100644 index 00000000000..c08760bf924 --- /dev/null +++ b/pyomo/contrib/coramin/algorithms/cut_gen.py @@ -0,0 +1,67 @@ +from pyomo.core.base.block import _BlockData +from pyomo.repn.standard_repn import generate_standard_repn, StandardRepn +from pyomo.core.expr.numeric_expr import LinearExpression +from pyomo.core.base.block import _BlockData +from pyomo.contrib.coramin.cutting_planes.alpha_bb_cuts import AlphaBBCutGenerator +from pyomo.contrib.coramin.cutting_planes.base import CutGenerator +from pyomo.contrib.coramin.utils.coramin_enums import EigenValueBounder +from typing import List +from pyomo.common.config import ConfigDict, ConfigValue + + +class AlphaBBConfig(ConfigDict): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super().__init__(description, doc, implicit, implicit_domain, visibility) + self.max_num_vars: int = self.declare("max_num_vars", ConfigValue(default=4)) + self.method: EigenValueBounder = self.declare( + "method", + ConfigValue(default=EigenValueBounder.GershgorinWithSimplification), + ) + + +def find_cut_generators(m: _BlockData, config: AlphaBBConfig) -> List[CutGenerator]: + cut_generators = list() + for c in m.nonlinear.cons.values(): + repn: StandardRepn = generate_standard_repn( + c.body, quadratic=False, compute_values=True + ) + if repn.nonlinear_expr is None: + continue + if len(repn.nonlinear_vars) > config.max_num_vars: + continue + + if len(repn.linear_coefs) > 0: + lhs = LinearExpression( + constant=repn.constant, + linear_coefs=repn.linear_coefs, + linear_vars=repn.linear_vars, + ) + else: + lhs = repn.constant + + # alpha bb convention is lhs >= rhs + if c.lb is not None: + cg = AlphaBBCutGenerator( + lhs=lhs - c.lb, + rhs=-repn.nonlinear_expr, + eigenvalue_opt=None, + method=config.method, + ) + cut_generators.append(cg) + if c.ub is not None: + cg = AlphaBBCutGenerator( + lhs=c.ub - lhs, + rhs=repn.nonlinear_expr, + eigenvalue_opt=None, + method=config.method, + ) + cut_generators.append(cg) + + return cut_generators diff --git a/pyomo/contrib/coramin/algorithms/ecp_bounder.py b/pyomo/contrib/coramin/algorithms/ecp_bounder.py new file mode 100644 index 00000000000..7c58a647fc5 --- /dev/null +++ b/pyomo/contrib/coramin/algorithms/ecp_bounder.py @@ -0,0 +1,331 @@ +from pyomo.contrib import appsi +from pyomo.common.collections import ComponentSet +from pyomo.contrib.coramin.utils.coramin_enums import RelaxationSide +import pyomo.environ as pe +import time +from pyomo.common.config import ConfigValue, NonNegativeInt, NonNegativeFloat, In +from pyomo.common.timing import HierarchicalTimer +from pyomo.core.staleflag import StaleFlagManager +from pyomo.contrib.coramin.utils import get_objective +from pyomo.contrib.coramin.relaxations import relaxation_data_objects +from typing import Optional +from typing import Sequence, Mapping, MutableMapping, Tuple, List +from pyomo.core.base.var import _GeneralVarData +from pyomo.core.base.constraint import _GeneralConstraintData +from pyomo.core.base.param import _ParamData + + +import logging + +logger = logging.getLogger(__name__) + + +class ECPConfig(appsi.base.SolverConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super(ECPConfig, self).__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + + self.feasibility_tol = self.declare( + "feasibility_tol", + ConfigValue( + default=1e-6, + domain=NonNegativeFloat, + doc="Tolerance below which cuts will not be added", + ), + ) + self.max_iter = self.declare( + "max_iter", + ConfigValue( + default=30, domain=NonNegativeInt, doc="Maximum number of iterations" + ), + ) + self.keep_cuts = self.declare( + "keep_cuts", + ConfigValue( + default=False, + domain=In([True, False]), + doc="Whether or not to keep the cuts generated after the solve", + ), + ) + + self.time_limit = 600 + + +class ECPSolutionLoader(appsi.base.SolutionLoaderBase): + def __init__(self, primals: MutableMapping[_GeneralVarData, float]): + self._primals = primals + + def get_primals( + self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None + ) -> Mapping[_GeneralVarData, float]: + if vars_to_load is None: + primals = pe.ComponentMap(self._primals) + else: + primals = pe.ComponentMap() + for v in vars_to_load: + primals[v] = self._primals[v] + return primals + + +class ECPResults(appsi.base.Results): + def __init__(self): + super(ECPResults, self).__init__() + self.wallclock_time = None + + +class ECPBounder(appsi.base.PersistentSolver): + """ + A solver designed for use inside of OBBT. This solver is a persistent solver for + efficient changes to the objective. Additionally, it provides a mechanism for + refining convex nonlinear constraints during OBBT. + """ + + def __init__(self, subproblem_solver: appsi.base.PersistentSolver): + super(ECPBounder, self).__init__() + self._subproblem_solver = subproblem_solver + self._relaxations = ComponentSet() + self._relaxations_with_added_cuts = ComponentSet() + self._pyomo_model = None + self._config = ECPConfig() + self._start_time: Optional[float] = None + self._update_config = appsi.base.UpdateConfig() + + def available(self): + return self._subproblem_solver.available() + + def version(self) -> Tuple: + return 0, 1, 0 + + @property + def symbol_map(self): + raise NotImplementedError("ECPBounder does not use a symbol map") + + @property + def config(self): + return self._config + + @config.setter + def config(self, val: ECPConfig): + self._config = val + + @property + def update_config(self) -> appsi.base.UpdateConfig: + return self._update_config + + @update_config.setter + def update_config(self, val: appsi.base.UpdateConfig): + self._update_config = val + + @property + def _elapsed_time(self): + return time.time() - self._start_time + + @property + def _remaining_time(self): + return max(0.0, self.config.time_limit - self._elapsed_time) + + def solve(self, model, timer: HierarchicalTimer = None) -> ECPResults: + self._start_time = time.time() + if timer is None: + timer = HierarchicalTimer() + timer.start("ECP Solve") + StaleFlagManager.mark_all_as_stale() + logger.info( + "{0:<10}{1:<12}{2:<12}{3:<12}{4:<12}".format( + "Iter", "objective", "max_viol", "time", "# cuts" + ) + ) + self._pyomo_model = model + + obj = get_objective(model) + if obj is None: + raise ValueError("Could not find any active objectives") + + final_res = ECPResults() + self._relaxations = ComponentSet() + self._relaxations_with_added_cuts = ComponentSet() + for b in relaxation_data_objects( + self._pyomo_model, descend_into=True, active=True + ): + self._relaxations.add(b) + + self._subproblem_solver.config.load_solution = False + orig_var_vals = pe.ComponentMap() + for v in self._pyomo_model.component_data_objects(pe.Var, descend_into=True): + orig_var_vals[v] = v.value + + all_added_cons = list() + for _iter in range(self.config.max_iter): + if self._elapsed_time >= self.config.time_limit: + final_res.termination_condition = ( + appsi.base.TerminationCondition.maxTimeLimit + ) + logger.warning("ECPBounder: time limit reached.") + break + self._subproblem_solver.config.time_limit = self._remaining_time + res = self._subproblem_solver.solve(self._pyomo_model, timer=timer) + if res.termination_condition == appsi.base.TerminationCondition.optimal: + res.solution_loader.load_vars() + else: + final_res.termination_condition = res.termination_condition + logger.warning("ECPBounder: subproblem did not terminate optimally") + break + + new_con_list = list() + max_viol = 0 + for b in self._relaxations: + viol = None + try: + if b.is_rhs_convex() and b.relaxation_side in { + RelaxationSide.BOTH, + RelaxationSide.UNDER, + }: + viol = pe.value(b.get_rhs_expr()) - pe.value(b.get_aux_var()) + elif b.is_rhs_concave() and b.relaxation_side in { + RelaxationSide.BOTH, + RelaxationSide.OVER, + }: + viol = pe.value(b.get_aux_var()) - pe.value(b.get_rhs_expr()) + except (OverflowError, ZeroDivisionError, ValueError) as err: + logger.warning("could not generate ECP cut due to " + str(err)) + if viol is not None: + if viol > max_viol: + max_viol = viol + if viol > self.config.feasibility_tol: + new_con = b.add_cut( + keep_cut=self.config.keep_cuts, + check_violation=True, + feasibility_tol=self.config.feasibility_tol, + ) + if new_con is not None: + self._relaxations_with_added_cuts.add(b) + new_con_list.append(new_con) + self._subproblem_solver.add_constraints(new_con_list) + + final_res.best_objective_bound = res.best_objective_bound + logger.info( + "{0:<10d}{1:<12.3e}{2:<12.3e}{3:<12.3e}{4:<12d}".format( + _iter, + final_res.best_objective_bound, + max_viol, + self._elapsed_time, + len(new_con_list), + ) + ) + + all_added_cons.extend(new_con_list) + + if len(new_con_list) == 0: + # The goal of the ECPBounder is not to find the optimal solution. + # Rather, the goal is just to get a decent bound quickly. + # However, if the problem is convex, we may still be able to declare + # optimality + final_res.termination_condition = ( + appsi.base.TerminationCondition.unknown + ) + logger.info("ECPBounder: converged!") + + found_feasible_solution = True + for b in self._relaxations: + deviation = b.get_deviation() + if deviation > self.config.feasibility_tol: + found_feasible_solution = False + + if found_feasible_solution: + final_res.termination_condition = ( + appsi.base.TerminationCondition.optimal + ) + final_res.best_feasible_objective = final_res.best_objective_bound + primal_sol = res.solution_loader.get_primals() + final_res.solution_loader = ECPSolutionLoader(primal_sol) + + break + + if _iter == self.config.max_iter - 1: + final_res.termination_condition = ( + appsi.base.TerminationCondition.maxIterations + ) + logger.warning("ECPBounder: reached maximum number of iterations") + + if not self.config.keep_cuts: + self._subproblem_solver.remove_constraints(all_added_cons) + for b in self._relaxations_with_added_cuts: + b.rebuild() + + if final_res.termination_condition == appsi.base.TerminationCondition.optimal: + if not self.config.load_solution: + for v, val in orig_var_vals.items(): + v.value = val + else: + if self.config.load_solution: + raise RuntimeError( + "A feasible solution was not found, so no solution can be loaded. " + "Please set opt.config.load_solution=False and check " + "results.termination_condition and results.best_feasible_objective " + "before loading a solution." + ) + for v, val in orig_var_vals.items(): + v.value = val + + final_res.wallclock_time = self._elapsed_time + timer.stop("ECP Solve") + return final_res + + def set_instance(self, model): + saved_update_config = self.update_config + saved_config = self.config + self.__init__(self._subproblem_solver) + self.config = saved_config + self.update_config = saved_update_config + self._pyomo_model = model + self._subproblem_solver.set_instance(model) + + def get_primals( + self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None + ) -> Mapping[_GeneralVarData, float]: + return self._subproblem_solver.get_primals(vars_to_load=vars_to_load) + + def add_block(self, block): + self._subproblem_solver.add_block(block) + + def add_constraints(self, cons: List[_GeneralConstraintData]): + self._subproblem_solver.add_constraints(cons) + + def add_variables(self, variables: List[_GeneralVarData]): + self._subproblem_solver.add_variables(variables=variables) + + def add_params(self, params: List[_ParamData]): + self._subproblem_solver.add_params(params=params) + + def remove_block(self, block): + self._subproblem_solver.remove_block(block) + + def remove_constraints(self, cons: List[_GeneralConstraintData]): + self._subproblem_solver.remove_constraints(cons=cons) + + def remove_variables(self, variables: List[_GeneralVarData]): + self._subproblem_solver.remove_variables(variables=variables) + + def remove_params(self, params: List[_ParamData]): + self._subproblem_solver.remove_params(params=params) + + def set_objective(self, obj): + self._subproblem_solver.set_objective(obj) + + def update_variables(self, variables: List[_GeneralVarData]): + self._subproblem_solver.update_variables(variables=variables) + + def update_params(self): + return self._subproblem_solver.update_params() diff --git a/pyomo/contrib/coramin/algorithms/multitree/__init__.py b/pyomo/contrib/coramin/algorithms/multitree/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/algorithms/multitree/multitree.py b/pyomo/contrib/coramin/algorithms/multitree/multitree.py new file mode 100644 index 00000000000..081ad0ad67e --- /dev/null +++ b/pyomo/contrib/coramin/algorithms/multitree/multitree.py @@ -0,0 +1,1047 @@ +import math +from pyomo.contrib.coramin.relaxations.relaxations_base import ( + BaseRelaxationData, + BasePWRelaxationData, +) +import pyomo.environ as pe +from pyomo.common.timing import HierarchicalTimer +from pyomo.core.base.block import _BlockData +from pyomo.contrib.appsi.base import ( + Results, + PersistentSolver, + Solver, + MIPSolverConfig, + TerminationCondition, + SolutionLoaderBase, + UpdateConfig, +) +from pyomo.contrib import appsi +from typing import Tuple, Optional, MutableMapping, Sequence +from pyomo.common.config import ( + ConfigValue, + NonNegativeInt, + PositiveFloat, + PositiveInt, + NonNegativeFloat, + InEnum, +) +import logging +from pyomo.contrib.coramin.relaxations.auto_relax import _relax_cloned_model +from pyomo.contrib.coramin.relaxations.iterators import relaxation_data_objects +from pyomo.contrib.coramin.utils.coramin_enums import ( + RelaxationSide, + Effort, + EigenValueBounder, +) +from pyomo.contrib.coramin.domain_reduction.obbt import perform_obbt +import time +from pyomo.core.base.var import _GeneralVarData +from pyomo.core.base.objective import _GeneralObjectiveData +from pyomo.contrib.coramin.utils.pyomo_utils import get_objective, active_vars +from pyomo.common.collections.component_set import ComponentSet +from pyomo.common.modeling import unique_component_name +from pyomo.common.errors import InfeasibleConstraintException +from pyomo.contrib.fbbt.fbbt import BoundsManager +from pyomo.common.dependencies import numpy as np +from pyomo.core.expr.visitor import identify_variables +from pyomo.contrib.coramin.clone import clone_shallow_active_flat, get_clone_and_var_map +from pyomo.contrib.coramin.algorithms.cut_gen import AlphaBBConfig, find_cut_generators +from pyomo.contrib.coramin.algorithms.alg_utils import ( + impose_structure, + collect_vars, + relax_integers, +) + + +logger = logging.getLogger(__name__) + + +class MultiTreeConfig(MIPSolverConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super(MultiTreeConfig, self).__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + + self.declare("solver_output_logger", ConfigValue()) + self.declare("log_level", ConfigValue(domain=NonNegativeInt)) + self.declare("feasibility_tolerance", ConfigValue(domain=PositiveFloat)) + self.declare("abs_gap", ConfigValue(domain=PositiveFloat)) + self.declare("max_partitions_per_iter", ConfigValue(domain=PositiveInt)) + self.declare("max_iter", ConfigValue(domain=NonNegativeInt)) + self.declare("root_obbt_max_iter", ConfigValue(domain=NonNegativeInt)) + self.declare("show_obbt_progress_bar", ConfigValue(domain=bool)) + self.declare("integer_tolerance", ConfigValue(domain=PositiveFloat)) + self.declare("small_coef", ConfigValue(domain=NonNegativeFloat)) + self.declare("large_coef", ConfigValue(domain=NonNegativeFloat)) + self.declare("safety_tol", ConfigValue(domain=NonNegativeFloat)) + self.declare("convexity_effort", ConfigValue(domain=InEnum(Effort))) + self.declare("obbt_at_new_incumbents", ConfigValue(domain=bool)) + self.declare("relax_integers_for_obbt", ConfigValue(domain=bool)) + + self.solver_output_logger = logger + self.log_level = logging.INFO + self.feasibility_tolerance = 1e-7 + self.integer_tolerance = 1e-4 + self.time_limit = 600 + self.abs_gap = 1e-4 + self.mip_gap = 0.001 + self.max_partitions_per_iter = 5 + self.max_iter = 1000 + self.root_obbt_max_iter = 1000 + self.show_obbt_progress_bar = False + self.small_coef = 1e-10 + self.large_coef = 1e5 + self.safety_tol = 1e-10 + self.convexity_effort = Effort.high + self.obbt_at_new_incumbents: bool = True + self.relax_integers_for_obbt: bool = True + + +def _is_problem_definitely_convex(m: _BlockData) -> bool: + res = True + for r in relaxation_data_objects(m, descend_into=True, active=True): + if r.relaxation_side == RelaxationSide.BOTH: + res = False + break + elif r.relaxation_side == RelaxationSide.UNDER and not r.is_rhs_convex(): + res = False + break + elif r.relaxation_side == RelaxationSide.OVER and not r.is_rhs_concave(): + res = False + break + return res + + +class MultiTreeResults(Results): + def __init__(self): + super().__init__() + self.wallclock_time = None + + +class MultiTreeSolutionLoader(SolutionLoaderBase): + def __init__(self, primals: MutableMapping): + self._primals = primals + + def get_primals( + self, vars_to_load: Optional[Sequence[_GeneralVarData]] = None + ) -> MutableMapping[_GeneralVarData, float]: + if vars_to_load is None: + return pe.ComponentMap(self._primals.items()) + else: + primals = pe.ComponentMap() + for v in vars_to_load: + primals[v] = self._primals[v] + return primals + + +def _fix_vars_with_close_bounds(varlist, tol=1e-12): + for v in varlist: + if v.is_fixed(): + v.setlb(v.value) + v.setub(v.value) + lb, ub = v.bounds + if lb is None or ub is None: + continue + if abs(ub - lb) <= tol * min(abs(lb), abs(ub)) + tol: + v.fix(0.5 * (lb + ub)) + + +class MultiTree(Solver): + def __init__(self, mip_solver: PersistentSolver, nlp_solver: PersistentSolver): + super(MultiTree, self).__init__() + self._config = MultiTreeConfig() + self.mip_solver: PersistentSolver = mip_solver + self.nlp_solver: PersistentSolver = nlp_solver + self._original_model: Optional[_BlockData] = None + self._relaxation: Optional[_BlockData] = None + self._nlp: Optional[_BlockData] = None + self._start_time: Optional[float] = None + self._incumbent: Optional[pe.ComponentMap] = None + self._best_feasible_objective: Optional[float] = None + self._best_objective_bound: Optional[float] = None + self._objective: Optional[_GeneralObjectiveData] = None + self._relaxation_objects: Optional[Sequence[BaseRelaxationData]] = None + self._stop: Optional[TerminationCondition] = None + self._discrete_vars: Optional[Sequence[_GeneralVarData]] = None + self._nlp_tightener: Optional[appsi.fbbt.IntervalTightener] = None + self._iter: int = 0 + self._cut_generators = None + + def _re_init(self): + self._original_model: Optional[_BlockData] = None + self._relaxation: Optional[_BlockData] = None + self._nlp: Optional[_BlockData] = None + self._start_time: Optional[float] = None + self._incumbent: Optional[pe.ComponentMap] = None + self._best_feasible_objective: Optional[float] = None + self._best_objective_bound: Optional[float] = None + self._objective: Optional[_GeneralObjectiveData] = None + self._relaxation_objects: Optional[Sequence[BaseRelaxationData]] = None + self._stop: Optional[TerminationCondition] = None + self._discrete_vars: Optional[Sequence[_GeneralVarData]] = None + self._rel_to_nlp_map: Optional[MutableMapping] = None + self._nlp_to_orig_map: Optional[MutableMapping] = None + self._nlp_tightener: Optional[appsi.fbbt.IntervalTightener] = None + self._iter: int = 0 + + def available(self): + if ( + self.mip_solver.available() == Solver.Availability.FullLicense + and self.nlp_solver.available() == Solver.Availability.FullLicense + ): + return Solver.Availability.FullLicense + elif self.mip_solver.available() == Solver.Availability.FullLicense: + return self.nlp_solver.available() + else: + return self.mip_solver.available() + + def version(self) -> Tuple: + return 0, 1, 0 + + @property + def config(self) -> MultiTreeConfig: + return self._config + + @config.setter + def config(self, val: MultiTreeConfig): + self._config = val + + @property + def symbol_map(self): + raise NotImplementedError("This solver does not have a symbol map") + + def _should_terminate(self) -> Tuple[bool, Optional[TerminationCondition]]: + if self._elapsed_time >= self.config.time_limit: + return True, TerminationCondition.maxTimeLimit + if self._iter >= self.config.max_iter: + return True, TerminationCondition.maxIterations + if self._stop is not None: + return True, self._stop + primal_bound = self._get_primal_bound() + dual_bound = self._get_dual_bound() + if self._objective.sense == pe.minimize: + assert ( + primal_bound + >= dual_bound - 1e-4 * max(abs(primal_bound), abs(dual_bound)) - 1e-4 + ) + else: + assert ( + primal_bound + <= dual_bound + 1e-6 * max(abs(primal_bound), abs(dual_bound)) + 1e-6 + ) + abs_gap, rel_gap = self._get_abs_and_rel_gap() + if abs_gap <= self.config.abs_gap: + return True, TerminationCondition.optimal + if rel_gap <= self.config.mip_gap: + return True, TerminationCondition.optimal + return False, TerminationCondition.unknown + + def _get_results( + self, termination_condition: TerminationCondition + ) -> MultiTreeResults: + res = MultiTreeResults() + res.termination_condition = termination_condition + res.best_feasible_objective = self._best_feasible_objective + res.best_objective_bound = self._best_objective_bound + if self._best_feasible_objective is not None: + res.solution_loader = MultiTreeSolutionLoader(self._incumbent) + res.wallclock_time = self._elapsed_time + + if self.config.load_solution: + if res.best_feasible_objective is not None: + if res.termination_condition != TerminationCondition.optimal: + logger.warning( + 'Loading a feasible but potentially sub-optimal ' + 'solution. Please check the termination condition.' + ) + res.solution_loader.load_vars() + else: + raise RuntimeError( + 'No feasible solution was found. Please ' + 'set opt.config.load_solution=False and check the ' + 'termination condition before loading a solution.' + ) + + return res + + def _get_primal_bound(self) -> float: + if self._best_feasible_objective is None: + if self._objective.sense == pe.minimize: + primal_bound = math.inf + else: + primal_bound = -math.inf + else: + primal_bound = self._best_feasible_objective + return primal_bound + + def _get_dual_bound(self) -> float: + if self._best_objective_bound is None: + if self._objective.sense == pe.minimize: + dual_bound = -math.inf + else: + dual_bound = math.inf + else: + dual_bound = self._best_objective_bound + return dual_bound + + def _get_abs_and_rel_gap(self): + primal_bound = self._get_primal_bound() + dual_bound = self._get_dual_bound() + abs_gap = abs(primal_bound - dual_bound) + if abs_gap == 0: + rel_gap = 0 + elif primal_bound == 0: + rel_gap = math.inf + elif math.isinf(abs_gap): + rel_gap = math.inf + else: + rel_gap = abs_gap / abs(primal_bound) + return abs_gap, rel_gap + + def _get_constr_violation(self): + viol_list = list() + if len(self._relaxation_objects) == 0: + return 0 + for b in self._relaxation_objects: + any_none = False + for v in b.get_rhs_vars(): + if v.value is None: + any_none = True + break + if any_none: + viol_list.append(math.inf) + break + else: + viol_list.append(b.get_deviation()) + return max(viol_list) + + def _log( + self, + header=False, + num_lb_improved=0, + num_ub_improved=0, + avg_lb_improvement=0, + avg_ub_improvement=0, + rel_termination=None, + nlp_termination=None, + constr_viol=None, + ): + logger = self.config.solver_output_logger + log_level = self.config.log_level + if header: + msg = ( + f"{'Iter':<6}{'Primal Bd':<12}{'Dual Bd':<12}{'Abs Gap':<9}" + f"{'% Gap':<7}{'CnstrVio':<10}{'Time':<6}{'NLP Term':<10}" + f"{'Rel Term':<10}{'#LBs':<6}{'#UBs':<6}{'Avg LB':<9}" + f"{'Avg UB':<9}" + ) + logger.log(log_level, msg) + if self.config.stream_solver: + print(msg) + else: + if rel_termination is None: + rel_termination = '-' + else: + rel_termination = str(rel_termination.name) + if nlp_termination is None: + nlp_termination = '-' + else: + nlp_termination = str(nlp_termination.name) + rel_termination = rel_termination[:9] + nlp_termination = nlp_termination[:9] + primal_bound = self._get_primal_bound() + dual_bound = self._get_dual_bound() + abs_gap, rel_gap = self._get_abs_and_rel_gap() + if constr_viol is None: + constr_viol = '-' + else: + constr_viol = f'{constr_viol:<10.1e}' + elapsed_time = self._elapsed_time + if elapsed_time < 100: + elapsed_time_str = f'{elapsed_time:<6.2f}' + else: + elapsed_time_str = f'{round(elapsed_time):<6d}' + percent_gap = rel_gap * 100 + if math.isinf(percent_gap): + percent_gap_str = f'{percent_gap:<7.2f}' + elif percent_gap >= 100: + percent_gap_str = f'{round(percent_gap):<7d}' + else: + percent_gap_str = f'{percent_gap:<7.2f}' + msg = ( + f"{self._iter:<6}{primal_bound:<12.3e}{dual_bound:<12.3e}" + f"{abs_gap:<9.1e}{percent_gap_str:<7}{constr_viol:<10}" + f"{elapsed_time_str:<6}{nlp_termination:<10}" + f"{rel_termination:<10}{num_lb_improved:<6}" + f"{num_ub_improved:<6}{avg_lb_improvement:<9.1e}" + f"{avg_ub_improvement:<9.1e}" + ) + logger.log(log_level, msg) + if self.config.stream_solver: + print(msg) + + def _update_dual_bound(self, res: Results): + if res.best_objective_bound is not None: + if self._objective.sense == pe.minimize: + if ( + self._best_objective_bound is None + or res.best_objective_bound > self._best_objective_bound + ): + self._best_objective_bound = res.best_objective_bound + else: + if ( + self._best_objective_bound is None + or res.best_objective_bound < self._best_objective_bound + ): + self._best_objective_bound = res.best_objective_bound + + if res.best_feasible_objective is not None: + max_viol = self._get_constr_violation() + if max_viol > self.config.feasibility_tolerance: + all_cons_satisfied = False + else: + all_cons_satisfied = True + if all_cons_satisfied: + for v in self._discrete_vars: + if v.value is None: + assert v.stale + continue + if not math.isclose( + v.value, + round(v.value), + rel_tol=self.config.integer_tolerance, + abs_tol=self.config.integer_tolerance, + ): + all_cons_satisfied = False + break + if all_cons_satisfied: + self._update_primal_bound(res) + + def _update_primal_bound(self, res: Results): + should_update = False + if res.best_feasible_objective is not None: + if self._objective.sense == pe.minimize: + if ( + self._best_feasible_objective is None + or res.best_feasible_objective < self._best_feasible_objective + ): + should_update = True + else: + if ( + self._best_feasible_objective is None + or res.best_feasible_objective > self._best_feasible_objective + ): + should_update = True + + if should_update: + self._best_feasible_objective = res.best_feasible_objective + self._incumbent = pe.ComponentMap() + for nlp_v, orig_v in self._orig_var_map.items(): + self._incumbent[orig_v] = nlp_v.value + + def _solve_nlp_with_fixed_vars( + self, + integer_var_values: MutableMapping[_GeneralVarData, float], + rhs_var_bounds: MutableMapping[_GeneralVarData, Tuple[float, float]], + ) -> Results: + self._iter += 1 + + orig_bounds = pe.ComponentMap() + for v in self._nlp.vars: + orig_bounds[v] = v.bounds + + fixed_vars = list() + for v in self._discrete_vars: + if v.fixed: + continue + val = integer_var_values[v] + assert math.isclose( + val, + round(val), + rel_tol=self.config.integer_tolerance, + abs_tol=self.config.integer_tolerance, + ) + val = round(val) + v.fix(val) + fixed_vars.append(v) + + for v, (v_lb, v_ub) in rhs_var_bounds.items(): + if v.fixed: + continue + v.setlb(v_lb) + v.setub(v_ub) + + nlp_res = Results() + + active_constraints = list() + for c in ComponentSet( + self._nlp.component_data_objects( + pe.Constraint, active=True, descend_into=True + ) + ): + active_constraints.append(c) + + try: + self._nlp_tightener.perform_fbbt(self._nlp) + proven_infeasible = False + except InfeasibleConstraintException: + # the original NLP may still be feasible + proven_infeasible = True + + if proven_infeasible: + any_unfixed_vars = False + for v in self._original_model.vars: + if not v.fixed: + any_unfixed_vars = True + break + if any_unfixed_vars: + self.nlp_solver.config.time_limit = self._remaining_time + nlp_res = self.nlp_solver.solve(self._original_model) + if nlp_res.best_feasible_objective is not None: + nlp_res.solution_loader.load_vars() + else: + nlp_res = Results() + nlp_res.termination_condition = TerminationCondition.infeasible + else: + for v in self._nlp.vars: + if v.fixed: + continue + if v.has_lb() and v.has_ub(): + if math.isclose( + v.lb, + v.ub, + rel_tol=self.config.feasibility_tolerance, + abs_tol=self.config.feasibility_tolerance, + ): + v.fix(0.5 * (v.lb + v.ub)) + fixed_vars.append(v) + else: + v.value = 0.5 * (v.lb + v.ub) + + any_unfixed_vars = False + for c in self._nlp.component_data_objects( + pe.Constraint, active=True, descend_into=True + ): + for v in identify_variables(c.body, include_fixed=False): + any_unfixed_vars = True + break + if not any_unfixed_vars: + for obj in self._nlp.component_data_objects( + pe.Objective, active=True, descend_into=True + ): + for v in identify_variables(obj.expr, include_fixed=False): + any_unfixed_vars = True + break + + if any_unfixed_vars: + self.nlp_solver.config.time_limit = self._remaining_time + self.nlp_solver.config.load_solution = False + try: + nlp_res = self.nlp_solver.solve(self._nlp) + solve_error = False + except Exception: + solve_error = True + if not solve_error and nlp_res.best_feasible_objective is not None: + nlp_res.solution_loader.load_vars() + else: + self.nlp_solver.config.time_limit = self._remaining_time + try: + nlp_res = self.nlp_solver.solve(self._original_model) + solve_error = False + except Exception: + solve_error = True + if not solve_error and nlp_res.best_feasible_objective is not None: + nlp_res.solution_loader.load_vars() + else: + nlp_obj = get_objective(self._nlp) + # there should not be any active constraints + # they should all have been deactivated by FBBT + for c in active_constraints: + assert not c.active + nlp_res.termination_condition = TerminationCondition.optimal + nlp_res.best_feasible_objective = pe.value(nlp_obj) + nlp_res.best_objective_bound = nlp_res.best_feasible_objective + nlp_res.solution_loader = MultiTreeSolutionLoader( + pe.ComponentMap((v, v.value) for v in self._nlp.vars) + ) + + self._update_primal_bound(nlp_res) + self._log(header=False, nlp_termination=nlp_res.termination_condition) + + for v in fixed_vars: + v.unfix() + + for v, (lb, ub) in orig_bounds.items(): + v.setlb(lb) + v.setub(ub) + + for c in active_constraints: + c.activate() + + return nlp_res + + def _solve_relaxation(self) -> Results: + self._iter += 1 + self.mip_solver.config.time_limit = self._remaining_time + self.mip_solver.config.load_solution = False + rel_res = self.mip_solver.solve(self._relaxation) + + if rel_res.best_feasible_objective is not None: + rel_res.solution_loader.load_vars() + + self._update_dual_bound(rel_res) + self._log( + header=False, + rel_termination=rel_res.termination_condition, + constr_viol=self._get_constr_violation(), + ) + if rel_res.termination_condition not in { + TerminationCondition.optimal, + TerminationCondition.maxTimeLimit, + TerminationCondition.maxIterations, + TerminationCondition.objectiveLimit, + TerminationCondition.interrupted, + }: + self._stop = rel_res.termination_condition + return rel_res + + def _partition_helper(self): + dev_list = list() + + err = False + + for b in self._relaxation_objects: + for v in b.get_rhs_vars(): + if not v.has_lb() or not v.has_ub(): + logger.error( + 'The multitree algorithm is not guaranteed to converge ' + 'for problems with unbounded variables. Please bound all ' + 'variables.' + ) + self._stop = TerminationCondition.error + err = True + break + if err: + break + + aux_val = pe.value(b.get_aux_var()) + rhs_val = pe.value(b.get_rhs_expr()) + if ( + aux_val > rhs_val + self.config.feasibility_tolerance + and b.relaxation_side in {RelaxationSide.BOTH, RelaxationSide.OVER} + and not b.is_rhs_concave() + ): + dev_list.append((b, aux_val - rhs_val)) + elif ( + aux_val < rhs_val - self.config.feasibility_tolerance + and b.relaxation_side in {RelaxationSide.BOTH, RelaxationSide.UNDER} + and not b.is_rhs_convex() + ): + dev_list.append((b, rhs_val - aux_val)) + + if not err: + dev_list.sort(key=lambda x: x[1], reverse=True) + + for b, dev in dev_list[: self.config.max_partitions_per_iter]: + b.add_partition_point() + b.rebuild() + + def _oa_cut_helper(self, tol): + new_con_list = list() + for b in self._relaxation_objects: + new_con = b.add_cut( + keep_cut=True, check_violation=True, feasibility_tol=tol + ) + if new_con is not None: + new_con_list.append(new_con) + self.mip_solver.add_constraints(new_con_list) + return new_con_list + + def _run_cut_generators(self, max_iter): + last_res = None + + for _iter in range(max_iter): + added_cuts = False + for cg in self._cut_generators: + cut_expr = cg.generate(None) + if cut_expr is not None: + self._relaxation.cuts.add(cut_expr) + if added_cuts: + if self._should_terminate()[0]: + break + rel_res = self._solve_relaxation() + last_res = Results() + last_res.best_feasible_objective = rel_res.best_feasible_objective + last_res.best_objective_bound = rel_res.best_objective_bound + last_res.termination_condition = rel_res.termination_condition + last_res.solution_loader = MultiTreeSolutionLoader( + rel_res.solution_loader.get_primals( + vars_to_load=self._discrete_vars + ) + ) + else: + break + + if last_res is None: + last_res = Results() + + return last_res + + def _add_oa_cuts(self, tol, max_iter) -> Results: + original_update_config: UpdateConfig = self.mip_solver.update_config() + + self.mip_solver.update() + + self.mip_solver.update_config.update_params = False + self.mip_solver.update_config.update_vars = False + self.mip_solver.update_config.update_objective = False + self.mip_solver.update_config.update_constraints = False + self.mip_solver.update_config.check_for_new_objective = False + self.mip_solver.update_config.check_for_new_or_removed_constraints = False + self.mip_solver.update_config.check_for_new_or_removed_vars = False + self.mip_solver.update_config.check_for_new_or_removed_params = True + self.mip_solver.update_config.treat_fixed_vars_as_params = True + self.mip_solver.update_config.update_named_expressions = False + + last_res = None + + for _iter in range(max_iter): + if self._should_terminate()[0]: + break + + rel_res = self._solve_relaxation() + if rel_res.best_feasible_objective is not None: + last_res = Results() + last_res.best_feasible_objective = rel_res.best_feasible_objective + last_res.best_objective_bound = rel_res.best_objective_bound + last_res.termination_condition = rel_res.termination_condition + last_res.solution_loader = MultiTreeSolutionLoader( + rel_res.solution_loader.get_primals( + vars_to_load=self._discrete_vars + ) + ) + + if self._should_terminate()[0]: + break + + new_con_list = self._oa_cut_helper(tol=tol) + if len(new_con_list) == 0: + break + + self.mip_solver.update_config.update_params = ( + original_update_config.update_params + ) + self.mip_solver.update_config.update_vars = original_update_config.update_vars + self.mip_solver.update_config.update_objective = ( + original_update_config.update_objective + ) + self.mip_solver.update_config.update_constraints = ( + original_update_config.update_constraints + ) + self.mip_solver.update_config.check_for_new_objective = ( + original_update_config.check_for_new_objective + ) + self.mip_solver.update_config.check_for_new_or_removed_constraints = ( + original_update_config.check_for_new_or_removed_constraints + ) + self.mip_solver.update_config.check_for_new_or_removed_vars = ( + original_update_config.check_for_new_or_removed_vars + ) + self.mip_solver.update_config.check_for_new_or_removed_params = ( + original_update_config.check_for_new_or_removed_params + ) + self.mip_solver.update_config.treat_fixed_vars_as_params = ( + original_update_config.treat_fixed_vars_as_params + ) + self.mip_solver.update_config.update_named_expressions = ( + original_update_config.update_named_expressions + ) + + if last_res is None: + last_res = Results() + + return last_res + + def _construct_nlp(self): + self._nlp = clone_shallow_active_flat(self._relaxation)[0] + for b in relaxation_data_objects(self._nlp, descend_into=True, active=True): + b.rebuild(build_nonlinear_constraint=True) + + def _construct_relaxation(self): + _relax_cloned_model(self._relaxation) + self._relaxation.cuts = pe.ConstraintList() + self._relaxation_objects = list() + for b in relaxation_data_objects( + self._relaxation, descend_into=True, active=True + ): + b.small_coef = self.config.small_coef + b.large_coef = self.config.large_coef + b.safety_tol = self.config.safety_tol + b.rebuild() + self._relaxation_objects.append(b) + + def _get_nlp_specs_from_rel(self): + integer_var_values = pe.ComponentMap() + for v in self._discrete_vars: + integer_var_values[v] = v.value + rhs_var_bounds = pe.ComponentMap() + for r in self._relaxation_objects: + if not isinstance(r, BasePWRelaxationData): + continue + any_unbounded_vars = False + for v in r.get_rhs_vars(): + if not v.has_lb() or not v.has_ub(): + any_unbounded_vars = True + break + if any_unbounded_vars: + continue + active_parts = r.get_active_partitions() + assert len(active_parts) == 1 + v, bnds = list(active_parts.items())[0] + if v in rhs_var_bounds: + existing_bnds = rhs_var_bounds[v] + bnds = (max(bnds[0], existing_bnds[0]), min(bnds[1], existing_bnds[1])) + assert bnds[0] <= bnds[1] + rhs_var_bounds[v] = bnds + return integer_var_values, rhs_var_bounds + + @property + def _elapsed_time(self): + return time.time() - self._start_time + + @property + def _remaining_time(self): + return max(0.0, self.config.time_limit - self._elapsed_time) + + def _perform_obbt(self, vars_to_tighten): + safety_tol = 1e-4 + self._iter += 1 + orig_lbs = list() + orig_ubs = list() + for v in vars_to_tighten: + v_lb, v_ub = v.bounds + if v_lb is None: + v_lb = -math.inf + if v_ub is None: + v_ub = math.inf + orig_lbs.append(v_lb) + orig_ubs.append(v_ub) + orig_lbs = np.array(orig_lbs) + orig_ubs = np.array(orig_ubs) + perform_obbt( + self._relaxation, + solver=self.mip_solver, + varlist=list(vars_to_tighten), + objective_bound=self._best_feasible_objective, + with_progress_bar=self.config.show_obbt_progress_bar, + time_limit=self._remaining_time, + ) + new_lbs = list() + new_ubs = list() + for ndx, v in enumerate(vars_to_tighten): + v_lb, v_ub = v.bounds + if v_lb is None: + v_lb = -math.inf + if v_ub is None: + v_ub = math.inf + v_lb -= safety_tol + v_ub += safety_tol + if v_lb < orig_lbs[ndx]: + v_lb = orig_lbs[ndx] + if v_ub > orig_ubs[ndx]: + v_ub = orig_ubs[ndx] + v.setlb(v_lb) + v.setub(v_ub) + new_lbs.append(v_lb) + new_ubs.append(v_ub) + for r in self._relaxation_objects: + r.rebuild() + new_lbs = np.array(new_lbs) + new_ubs = np.array(new_ubs) + lb_diff = new_lbs - orig_lbs + ub_diff = orig_ubs - new_ubs + lb_improved = lb_diff > 1e-3 + ub_improved = ub_diff > 1e-3 + lb_improved_indices = lb_improved.nonzero()[0] + ub_improved_indices = ub_improved.nonzero()[0] + num_lb_improved = len(lb_improved_indices) + num_ub_improved = len(ub_improved_indices) + if num_lb_improved > 0: + avg_lb_improvement = np.mean(lb_diff[lb_improved_indices]) + else: + avg_lb_improvement = 0 + if num_ub_improved > 0: + avg_ub_improvement = np.mean(ub_diff[ub_improved_indices]) + else: + avg_ub_improvement = 0 + self._log( + header=False, + num_lb_improved=num_lb_improved, + num_ub_improved=num_ub_improved, + avg_lb_improvement=avg_lb_improvement, + avg_ub_improvement=avg_ub_improvement, + ) + + return num_lb_improved, num_ub_improved, avg_lb_improvement, avg_ub_improvement + + def solve( + self, model: _BlockData, timer: HierarchicalTimer = None + ) -> MultiTreeResults: + self._re_init() + + self._start_time = time.time() + if timer is None: + timer = HierarchicalTimer() + timer.start("solve") + + model, self._orig_var_map = get_clone_and_var_map(model) + + # prevent the model from being garbage collected; + # otherwise the variables will become "unattached" + self.truly_original_model = model + + self._original_model, self._relaxation = clone_shallow_active_flat(model, 2) + model = self._original_model + + self._log(header=True) + + timer.start("construct relaxation") + impose_structure(self._relaxation) + self._cut_generators = find_cut_generators(self._relaxation, AlphaBBConfig()) + self._construct_relaxation() + self._construct_nlp() + timer.stop("construct relaxation") + + it = appsi.fbbt.IntervalTightener() + it.config.deactivate_satisfied_constraints = True + it.perform_fbbt(self._relaxation) + it.perform_fbbt(self._nlp) + _fix_vars_with_close_bounds(self._nlp.vars) + + self._objective = get_objective(self._relaxation) + + should_terminate, reason = self._should_terminate() + if should_terminate: + return self._get_results(reason) + + self._log(header=False) + + self.mip_solver.set_instance(self._relaxation) + self._nlp_tightener = appsi.fbbt.IntervalTightener() + self._nlp_tightener.config.deactivate_satisfied_constraints = True + self._nlp_tightener.config.feasibility_tol = self.config.feasibility_tolerance + self._nlp_tightener.set_instance(self._nlp, symbolic_solver_labels=False) + + bin_vars, int_vars = collect_vars(self._relaxation) + relax_integers(bin_vars, int_vars) + self._discrete_vars = list(bin_vars) + list(int_vars) + oa_results = self._add_oa_cuts(self.config.feasibility_tolerance * 100, 100) + oa_results = self._run_cut_generators(100) + for v in bin_vars: + v.domain = pe.Binary + for v in int_vars: + v.domain = pe.Integers + + should_terminate, reason = self._should_terminate() + if should_terminate: + return self._get_results(reason) + + if _is_problem_definitely_convex(self._relaxation): + oa_results = self._add_oa_cuts(self.config.feasibility_tolerance, 100) + else: + oa_results = self._add_oa_cuts(self.config.feasibility_tolerance * 1e3, 3) + + should_terminate, reason = self._should_terminate() + if should_terminate: + return self._get_results(reason) + + if oa_results.best_feasible_objective is not None: + integer_var_values, rhs_var_bounds = self._get_nlp_specs_from_rel() + nlp_res = self._solve_nlp_with_fixed_vars( + integer_var_values, rhs_var_bounds + ) + + vars_to_tighten = ComponentSet() + for r in relaxation_data_objects( + self._relaxation, descend_into=True, active=True + ): + vars_to_tighten.update(r.get_rhs_vars()) + vars_to_tighten = list(vars_to_tighten) + for obbt_iter in range(self.config.root_obbt_max_iter): + should_terminate, reason = self._should_terminate() + if should_terminate: + return self._get_results(reason) + relax_integers(bin_vars, int_vars) + num_lb, num_ub, avg_lb, avg_ub = self._perform_obbt(vars_to_tighten) + it.perform_fbbt(self._nlp) + for v in bin_vars: + v.domain = pe.Binary + for v in int_vars: + v.domain = pe.Integers + should_terminate, reason = self._should_terminate() + if (num_lb + num_ub) < 1 or (avg_lb < 1e-3 and avg_ub < 1e-3): + break + if should_terminate: + return self._get_results(reason) + self._solve_relaxation() + + while True: + should_terminate, reason = self._should_terminate() + if should_terminate: + break + + rel_res = self._solve_relaxation() + + should_terminate, reason = self._should_terminate() + if should_terminate: + break + + if rel_res.best_feasible_objective is not None: + self._oa_cut_helper(self.config.feasibility_tolerance) + self._partition_helper() + + integer_var_values, rhs_var_bounds = self._get_nlp_specs_from_rel() + start_primal_bound = self._get_primal_bound() + nlp_res = self._solve_nlp_with_fixed_vars( + integer_var_values, rhs_var_bounds + ) + end_primal_bound = self._get_primal_bound() + + should_terminate, reason = self._should_terminate() + if should_terminate: + break + + if self.config.obbt_at_new_incumbents and not math.isclose( + start_primal_bound, end_primal_bound, rel_tol=1e-4, abs_tol=1e-4 + ): + if self.config.relax_integers_for_obbt: + relax_integers(bin_vars, int_vars) + num_lb, num_ub, avg_lb, avg_ub = self._perform_obbt(vars_to_tighten) + it.perform_fbbt(self._nlp) + if self.config.relax_integers_for_obbt: + for v in bin_vars: + v.domain = pe.Binary + for v in int_vars: + v.domain = pe.Integers + else: + self.config.solver_output_logger.warning( + f"relaxation did not find a feasible solution: " + f"{rel_res.termination_condition}" + ) + + res = self._get_results(reason) + + timer.stop("solve") + + return res diff --git a/pyomo/contrib/coramin/algorithms/multitree/tests/__init__.py b/pyomo/contrib/coramin/algorithms/multitree/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/algorithms/multitree/tests/test_multitree.py b/pyomo/contrib/coramin/algorithms/multitree/tests/test_multitree.py new file mode 100644 index 00000000000..75a5adb8498 --- /dev/null +++ b/pyomo/contrib/coramin/algorithms/multitree/tests/test_multitree.py @@ -0,0 +1,329 @@ +import math + +from pyomo.contrib import coramin +from pyomo.contrib.coramin.third_party.minlplib_tools import ( + get_minlplib, + parse_osil_file, +) +from pyomo.common import unittest +from pyomo.contrib import appsi +import os +import logging +import math +from pyomo.common import download +import pyomo.environ as pe +from pyomo.core.base.block import _BlockData +import shutil + + +ipopt_available = pe.SolverFactory('ipopt').available() +gurobi_available = appsi.solvers.Gurobi().available() + + +def _get_sol(pname): + start_x1_set = {'batch0812', 'chem'} + current_dir = os.getcwd() + target_fname = os.path.join(current_dir, f'{pname}.sol') + downloader = download.FileDownloader() + downloader.set_destination_filename(target_fname) + downloader.get_binary_file(f'http://www.minlplib.org/sol/{pname}.p1.sol') + res = dict() + f = open(target_fname, 'r') + for line in f.readlines(): + l = line.split() + vname = l[0] + vval = float(l[1]) + if vname == 'objvar': + continue + assert vname.startswith('x') or vname.startswith('b') + res[vname] = vval + f.close() + return res + + +class Helper(unittest.TestCase): + def _check_relative_diff(self, expected, got, abs_tol=1e-3, rel_tol=1e-3): + abs_diff = abs(expected - got) + if expected == 0: + rel_diff = math.inf + else: + rel_diff = abs_diff / abs(expected) + success = abs_diff <= abs_tol or rel_diff <= rel_tol + self.assertTrue( + success, + msg=f'\n expected: {expected}\n got: {got}\n abs diff: {abs_diff}\n rel diff: {rel_diff}', + ) + + +@unittest.skipUnless(ipopt_available and gurobi_available, 'need both ipopt and gurobi') +class TestMultiTreeWithMINLPLib(Helper): + @classmethod + def setUpClass(self) -> None: + self.test_problems = { + 'batch0812': 2687026.784, + 'ball_mk3_10': None, + 'ball_mk2_10': 0, + 'syn05m': 837.73240090, + 'autocorr_bern20-03': -72, + 'chem': -47.70651483, + 'alkyl': -1.76499965, + } + self.primal_sol = dict() + self.primal_sol['batch0812'] = _get_sol('batch0812') + self.primal_sol['alkyl'] = _get_sol('alkyl') + self.primal_sol['ball_mk2_10'] = _get_sol('ball_mk2_10') + self.primal_sol['syn05m'] = _get_sol('syn05m') + self.primal_sol['autocorr_bern20-03'] = _get_sol('autocorr_bern20-03') + self.primal_sol['chem'] = _get_sol('chem') + for pname in self.test_problems.keys(): + get_minlplib(problem_name=pname, format='osil') + mip_solver = appsi.solvers.Gurobi() + nlp_solver = appsi.solvers.Ipopt() + nlp_solver.config.log_level = logging.DEBUG + self.opt = coramin.algorithms.MultiTree( + mip_solver=mip_solver, nlp_solver=nlp_solver + ) + + @classmethod + def tearDownClass(self) -> None: + current_dir = os.getcwd() + for pname in self.test_problems.keys(): + os.remove(os.path.join(current_dir, 'minlplib', 'osil', f'{pname}.osil')) + shutil.rmtree(os.path.join(current_dir, 'minlplib', 'osil')) + os.rmdir(os.path.join(current_dir, 'minlplib')) + for pname in self.primal_sol.keys(): + os.remove(os.path.join(current_dir, f'{pname}.sol')) + + def get_model(self, pname): + current_dir = os.getcwd() + fname = os.path.join(current_dir, 'minlplib', 'osil', f'{pname}.osil') + m = parse_osil_file(fname) + return m + + def _check_primal_sol(self, pname, m: _BlockData, res: appsi.base.Results): + expected_by_str = self.primal_sol[pname] + expected_by_var = pe.ComponentMap() + for vname, vval in expected_by_str.items(): + v = m.vars[vname] + expected_by_var[v] = vval + got = res.solution_loader.get_primals() + for v, val in expected_by_var.items(): + self._check_relative_diff(val, got[v]) + got = res.solution_loader.get_primals(vars_to_load=list(expected_by_var.keys())) + for v, val in expected_by_var.items(): + self._check_relative_diff(val, got[v]) + + def optimal_helper(self, pname, check_primal_sol=True): + m = self.get_model(pname) + res = self.opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self._check_relative_diff( + self.test_problems[pname], + res.best_feasible_objective, + abs_tol=self.opt.config.abs_gap, + rel_tol=self.opt.config.mip_gap, + ) + self._check_relative_diff( + self.test_problems[pname], + res.best_objective_bound, + abs_tol=self.opt.config.abs_gap, + rel_tol=self.opt.config.mip_gap, + ) + if check_primal_sol: + self._check_primal_sol(pname, m, res) + + def infeasible_helper(self, pname): + m = self.get_model(pname) + self.opt.config.load_solution = False + res = self.opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.infeasible + ) + self.opt.config.load_solution = True + + def time_limit_helper(self, pname): + orig_time_limit = self.opt.config.time_limit + self.opt.config.load_solution = False + for new_limit in [0, 0.1, 0.2, 0.3]: + self.opt.config.time_limit = new_limit + m = self.get_model(pname) + res = self.opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.maxTimeLimit + ) + self.opt.config.load_solution = True + + def test_batch0812(self): + self.optimal_helper('batch0812') + + def test_ball_mk2_10(self): + self.optimal_helper('ball_mk2_10') + + def test_alkyl(self): + self.optimal_helper('alkyl') + + def test_syn05m(self): + self.optimal_helper('syn05m') + + def test_autocorr_bern20_03(self): + self.optimal_helper('autocorr_bern20-03', check_primal_sol=False) + + def test_chem(self): + orig_config = self.opt.config() + self.opt.config.root_obbt_max_iter = 10 + self.opt.config.mip_gap = 0.05 + self.optimal_helper('chem') + self.opt.config = orig_config + + def test_time_limit(self): + self.time_limit_helper('chem') + + def test_ball_mk3_10(self): + self.infeasible_helper('ball_mk3_10') + + def test_available(self): + avail = self.opt.available() + assert avail in appsi.base.Solver.Availability + + +@unittest.skipUnless(ipopt_available and gurobi_available, 'need both ipopt and gurobi') +class TestMultiTree(Helper): + def test_convex_overestimator(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, 1)) + m.y = pe.Var() + m.obj = pe.Objective(expr=(m.x + 1) ** 2 - 0.2 * m.y) + m.c = pe.Constraint(expr=m.y <= m.x**2) + mip_solver = appsi.solvers.Gurobi() + nlp_solver = appsi.solvers.Ipopt() + nlp_solver.config.log_level = logging.DEBUG + opt = coramin.algorithms.MultiTree(mip_solver=mip_solver, nlp_solver=nlp_solver) + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self._check_relative_diff( + -0.25, + res.best_feasible_objective, + abs_tol=opt.config.abs_gap, + rel_tol=opt.config.mip_gap, + ) + self._check_relative_diff( + -0.25, + res.best_objective_bound, + abs_tol=opt.config.abs_gap, + rel_tol=opt.config.mip_gap, + ) + self._check_relative_diff(-1.250953, m.x.value, 1e-2, 1e-2) + self._check_relative_diff(1.5648825, m.y.value, 1e-2, 1e-2) + + def test_max_iter(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, 1)) + m.y = pe.Var() + m.obj = pe.Objective(expr=(m.x + 1) ** 2 - 0.2 * m.y) + m.c = pe.Constraint(expr=m.y <= m.x**2) + mip_solver = appsi.solvers.Gurobi() + nlp_solver = appsi.solvers.Ipopt() + nlp_solver.config.log_level = logging.DEBUG + opt = coramin.algorithms.MultiTree(mip_solver=mip_solver, nlp_solver=nlp_solver) + opt.config.max_iter = 3 + opt.config.load_solution = False + opt.config.stream_solver = True + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.maxIterations + ) + self.assertIsNone(res.best_feasible_objective) + opt.config.max_iter = 12 + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.maxIterations + ) + self.assertIsNotNone(res.best_feasible_objective) + + def test_nlp_infeas_fbbt(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, 1), domain=pe.Integers) + m.y = pe.Var(domain=pe.Integers) + m.obj = pe.Objective(expr=(m.x + 1) ** 2 - 0.2 * m.y) + m.c1 = pe.Constraint(expr=m.y <= (m.x - 0.5) ** 2 - 0.5) + m.c2 = pe.Constraint(expr=m.y >= -((m.x + 2) ** 2) + 4) + m.c3 = pe.Constraint(expr=m.y <= 2 * m.x + 7) + m.c4 = pe.Constraint(expr=m.y >= m.x) + mip_solver = appsi.solvers.Gurobi() + nlp_solver = appsi.solvers.Ipopt() + nlp_solver.config.log_level = logging.DEBUG + opt = coramin.algorithms.MultiTree(mip_solver=mip_solver, nlp_solver=nlp_solver) + opt.config.load_solution = False + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.infeasible + ) + + def test_all_vars_fixed_in_nlp(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, 1)) + m.y = pe.Var(domain=pe.Integers) + m.z = pe.Var() + m.obj = pe.Objective(expr=m.z - 0.2 * m.y) + m.c1 = pe.Constraint(expr=m.y == (m.x - 0.5) ** 2 - 0.5) + m.c2 = pe.Constraint(expr=m.z == (m.x + 1) ** 2) + mip_solver = appsi.solvers.Gurobi() + nlp_solver = appsi.solvers.Ipopt() + nlp_solver.config.log_level = logging.DEBUG + opt = coramin.algorithms.MultiTree(mip_solver=mip_solver, nlp_solver=nlp_solver) + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self._check_relative_diff(-0.462486082, res.best_feasible_objective) + self._check_relative_diff(-0.462486082, res.best_objective_bound) + self._check_relative_diff(-1.37082869, m.x.value) + self._check_relative_diff(3, m.y.value) + self._check_relative_diff(0.137513918, m.z.value) + + def test_linear_problem(self): + m = pe.ConcreteModel() + m.x = pe.Var() + m.y = pe.Var() + m.obj = pe.Objective(expr=m.y) + m.c1 = pe.Constraint(expr=m.y >= m.x + 1) + m.c2 = pe.Constraint(expr=m.y >= -m.x + 1) + mip_solver = appsi.solvers.Gurobi() + nlp_solver = appsi.solvers.Ipopt() + opt = coramin.algorithms.MultiTree(mip_solver=mip_solver, nlp_solver=nlp_solver) + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self.assertAlmostEqual(res.best_feasible_objective, 1) + self.assertAlmostEqual(res.best_objective_bound, 1) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + + def test_stale_fixed_vars(self): + m = pe.ConcreteModel() + m.x = pe.Var() + m.y = pe.Var() + m.z = pe.Var(domain=pe.Binary) + m.w = pe.Var() + m.obj = pe.Objective(expr=m.y) + m.c1 = pe.Constraint(expr=m.y >= m.x + 1) + m.c2 = pe.Constraint(expr=m.y >= -m.x + 1) + m.c3 = pe.Constraint(expr=m.w == 2) + mip_solver = appsi.solvers.Gurobi() + nlp_solver = appsi.solvers.Ipopt() + opt = coramin.algorithms.MultiTree(mip_solver=mip_solver, nlp_solver=nlp_solver) + res = opt.solve(m) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self.assertAlmostEqual(res.best_feasible_objective, 1) + self.assertAlmostEqual(res.best_objective_bound, 1) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) + self.assertAlmostEqual(m.w.value, 2) + self.assertIsNone(m.z.value) diff --git a/pyomo/contrib/coramin/algorithms/tests/__init__.py b/pyomo/contrib/coramin/algorithms/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/algorithms/tests/test_ecp_bounder.py b/pyomo/contrib/coramin/algorithms/tests/test_ecp_bounder.py new file mode 100644 index 00000000000..1a0ad410360 --- /dev/null +++ b/pyomo/contrib/coramin/algorithms/tests/test_ecp_bounder.py @@ -0,0 +1,27 @@ +import pyomo.environ as pe +from pyomo.contrib import coramin +from pyomo.contrib.coramin.algorithms.ecp_bounder import ECPBounder +from pyomo.common import unittest +from pyomo.contrib import appsi + + +gurobi_available = appsi.solvers.Gurobi().available() + + +class TestECPBounder(unittest.TestCase): + @unittest.skipUnless(gurobi_available, 'gurobi is not available') + def test_ecp_bounder(self): + m = pe.ConcreteModel() + m.x = pe.Var() + m.y = pe.Var() + m.obj = pe.Objective(expr=0.5 * (m.x**2 + m.y**2)) + m.c1 = pe.Constraint(expr=m.y >= (m.x - 1) ** 2) + m.c2 = pe.Constraint(expr=m.y >= pe.exp(m.x)) + r = coramin.relaxations.relax(m) + opt = ECPBounder(subproblem_solver=appsi.solvers.Gurobi()) + res = opt.solve(r) + self.assertEqual( + res.termination_condition, appsi.base.TerminationCondition.optimal + ) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, 1) diff --git a/pyomo/contrib/coramin/clone.py b/pyomo/contrib/coramin/clone.py new file mode 100644 index 00000000000..bf21e05a71f --- /dev/null +++ b/pyomo/contrib/coramin/clone.py @@ -0,0 +1,97 @@ +from .relaxations import iterators +from .relaxations.copy_relaxation import copy_relaxation_with_local_data +import pyomo.environ as pe +from .utils.pyomo_utils import get_objective +from pyomo.repn.standard_repn import generate_standard_repn +from pyomo.common.collections import ComponentSet, ComponentMap +from pyomo.core.base.block import _BlockData +from typing import List +from pyomo.core.expr.visitor import identify_variables +from pyomo.common.modeling import unique_component_name + + +def get_clone_and_var_map(m1: _BlockData): + orig_vars = list() + for c in iterators.nonrelaxation_component_data_objects( + m1, pe.Constraint, active=True, descend_into=True + ): + for v in identify_variables(c.body, include_fixed=False): + orig_vars.append(v) + obj = get_objective(m1) + if obj is not None: + for v in identify_variables(obj.expr, include_fixed=False): + orig_vars.append(v) + for r in iterators.relaxation_data_objects(m1, descend_into=True, active=True): + orig_vars.extend(r.get_rhs_vars()) + orig_vars.append(r.get_aux_var()) + orig_vars = list(ComponentSet(orig_vars)) + tmp_name = unique_component_name(m1, "active_vars") + setattr(m1, tmp_name, orig_vars) + m2 = m1.clone() + new_vars = getattr(m2, tmp_name) + var_map = ComponentMap(zip(new_vars, orig_vars)) + delattr(m1, tmp_name) + delattr(m2, tmp_name) + return m2, var_map + + +def clone_shallow_active_flat(m1: _BlockData, num_clones: int = 1) -> List[_BlockData]: + clone_list = [pe.Block(concrete=True) for i in range(num_clones)] + for m2 in clone_list: + m2.linear = pe.Block() + m2.nonlinear = pe.Block() + m2.linear.cons = pe.ConstraintList() + m2.nonlinear.cons = pe.ConstraintList() + all_vars = ComponentSet() + + # constraints + for c in iterators.nonrelaxation_component_data_objects( + m1, pe.Constraint, active=True, descend_into=True + ): + repn = generate_standard_repn(c.body, quadratic=False, compute_values=True) + all_vars.update(repn.linear_vars) + all_vars.update(repn.nonlinear_vars) + body = repn.to_expression() + if repn.nonlinear_expr is None: + for m2 in clone_list: + m2.linear.cons.add((c.lb, body, c.ub)) + else: + for m2 in clone_list: + m2.nonlinear.cons.add((c.lb, body, c.ub)) + + # objective + obj = get_objective(m1) + if obj is not None: + repn = generate_standard_repn(obj.expr, quadratic=False, compute_values=True) + all_vars.update(repn.linear_vars) + all_vars.update(repn.nonlinear_vars) + obj_expr = repn.to_expression() + if repn.nonlinear_expr is None: + for m2 in clone_list: + m2.linear.obj = pe.Objective(expr=obj_expr, sense=obj.sense) + else: + for m2 in clone_list: + m2.nonlinear.obj = pe.Objective(expr=obj_expr, sense=obj.sense) + + rel_list = list() + for r in iterators.relaxation_data_objects(m1, descend_into=True, active=True): + rel_list.append(r) + + for ndx, r in enumerate(rel_list): + var_map = dict() + for v in r.get_rhs_vars(): + if not v.is_fixed(): + all_vars.add(v) + var_map[id(v)] = v + aux_var = r.get_aux_var() + var_map[id(aux_var)] = aux_var + if not pe.is_fixed(aux_var): + all_vars.add(aux_var) + new_rel = copy_relaxation_with_local_data(r, var_map) + for m2 in clone_list: + setattr(m2, f'rel{ndx}', new_rel) + + for m2 in clone_list: + m2.vars = list(all_vars) + + return clone_list diff --git a/pyomo/contrib/coramin/cutting_planes/__init__.py b/pyomo/contrib/coramin/cutting_planes/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/cutting_planes/alpha_bb_cuts.py b/pyomo/contrib/coramin/cutting_planes/alpha_bb_cuts.py new file mode 100644 index 00000000000..8c4f9a45e43 --- /dev/null +++ b/pyomo/contrib/coramin/cutting_planes/alpha_bb_cuts.py @@ -0,0 +1,75 @@ +from pyomo.core.base.block import _BlockData +from .base import CutGenerator +from pyomo.core.base.var import _GeneralVarData +from pyomo.core.expr.numeric_expr import NumericExpression, NumericValue +from pyomo.core.expr.visitor import identify_variables +from typing import List, Optional, Union +from pyomo.contrib.coramin.relaxations.hessian import Hessian +from pyomo.contrib.coramin.utils.coramin_enums import EigenValueBounder +from pyomo.contrib.appsi.base import Solver +from pyomo.core.expr.visitor import value +from pyomo.core.expr.relational_expr import RelationalExpression +from pyomo.core.expr.taylor_series import taylor_series_expansion +import pybnb + + +class AlphaBBCutGenerator(CutGenerator): + def __init__( + self, + lhs: Union[float, int, NumericValue], + rhs: NumericExpression, + eigenvalue_opt: Optional[Solver] = None, + method: EigenValueBounder = EigenValueBounder.GershgorinWithSimplification, + feasibility_tol: float = 1e-6, + ) -> None: + self.lhs = lhs + self.rhs = rhs + self.xlist: List[_GeneralVarData] = list( + identify_variables(rhs, include_fixed=False) + ) + self.hessian = Hessian(expr=rhs, opt=eigenvalue_opt, method=method) + self.feasibility_tol = feasibility_tol + self._proven_convex = dict() + + def _most_recent_ancestor(self, node: pybnb.Node): + res = None + while res is None: + p = node.state.parent + if p is None: + break + if p in self._proven_convex: + res = p + break + node = p + return res + + def generate(self, node: Optional[pybnb.Node]) -> Optional[RelationalExpression]: + try: + lhs_val = value(self.lhs) + except ValueError: + return None + if lhs_val + self.feasibility_tol >= value(self.rhs): + return None + + if node is None: + mra = None + else: + mra = self._most_recent_ancestor(node) + if mra in self._proven_convex and self._proven_convex[mra]: + alpha_bb_rhs = self.rhs + else: + alpha = max(0, -0.5 * self.hessian.get_minimum_eigenvalue()) + if alpha == 0: + self._proven_convex[node] = True + alpha_bb_rhs = self.rhs + else: + self._proven_convex[node] = False + alpha_sum = 0 + for ndx, v in enumerate(self.xlist): + lb, ub = v.bounds + alpha_sum += (v - lb) * (v - ub) + alpha_bb_rhs = self.rhs + alpha * alpha_sum + if lhs_val + self.feasibility_tol >= value(alpha_bb_rhs): + return None + + return self.lhs >= taylor_series_expansion(alpha_bb_rhs) diff --git a/pyomo/contrib/coramin/cutting_planes/base.py b/pyomo/contrib/coramin/cutting_planes/base.py new file mode 100644 index 00000000000..1e9536c2c38 --- /dev/null +++ b/pyomo/contrib/coramin/cutting_planes/base.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod +from pyomo.core.base.block import _BlockData +from pyomo.contrib import appsi +from typing import Optional +import pybnb + + +class CutGenerator(ABC): + @abstractmethod + def generate(self, node: Optional[pybnb.Node]): + pass diff --git a/pyomo/contrib/coramin/domain_reduction/__init__.py b/pyomo/contrib/coramin/domain_reduction/__init__.py new file mode 100644 index 00000000000..02d751b7292 --- /dev/null +++ b/pyomo/contrib/coramin/domain_reduction/__init__.py @@ -0,0 +1,22 @@ +from .obbt import perform_obbt +from .filters import filter_variables_from_solution, aggressive_filter + +try: + from .dbt import ( + decompose_model, + perform_dbt, + perform_dbt_with_integers_relaxed, + TreeBlockData, + TreeBlock, + DecompositionError, + TreeBlockError, + collect_vars_to_tighten, + collect_vars_to_tighten_by_block, + DBTInfo, + push_integers, + pop_integers, + OBBTMethod, + FilterMethod, + ) +except: + pass diff --git a/pyomo/contrib/coramin/domain_reduction/dbt.py b/pyomo/contrib/coramin/domain_reduction/dbt.py new file mode 100644 index 00000000000..984b7842c6b --- /dev/null +++ b/pyomo/contrib/coramin/domain_reduction/dbt.py @@ -0,0 +1,1578 @@ +import networkx as nx +from typing import Sequence, MutableSet, Optional, Union +from pyomo.contrib.fbbt.fbbt import fbbt, compute_bounds_on_expr +import time +import enum +import warnings +from pyomo.solvers.plugins.solvers.persistent_solver import PersistentSolver +from .obbt import perform_obbt as normal_obbt +from .filters import aggressive_filter +import pyomo.environ as pe +from pyomo.core.expr.visitor import identify_variables +from pyomo.common.collections import ComponentSet +from pyomo.common.collections.orderedset import OrderedSet +from pyomo.contrib.coramin.relaxations.iterators import ( + relaxation_data_objects, + nonrelaxation_component_data_objects, +) +from pyomo.core.expr.visitor import replace_expressions +import logging +import networkx +from pyomo.contrib.coramin.clone import clone_shallow_active_flat +from pyomo.repn.standard_repn import generate_standard_repn + +try: + import metis + + metis_available = True +except: + metis_available = False +from pyomo.common.dependencies import numpy as np +import math +from pyomo.core.base.block import declare_custom_block, _BlockData +from pyomo.contrib.coramin.utils.pyomo_utils import get_objective +from pyomo.core.base.var import _GeneralVarData +from pyomo.contrib.coramin.relaxations.copy_relaxation import ( + copy_relaxation_with_local_data, +) +from pyomo.contrib.coramin.relaxations.relaxations_base import BaseRelaxationData +from pyomo.contrib.coramin.utils import RelaxationSide +from collections import defaultdict +from pyomo.core.expr import numeric_expr +from pyomo.core.expr.visitor import ExpressionValueVisitor, nonpyomo_leaf_types +from pyomo.common.modeling import unique_component_name +from pyomo.contrib.coramin.relaxations.split_expr import flatten_expr + + +logger = logging.getLogger(__name__) + + +class DecompositionError(Exception): + pass + + +class TreeBlockError(DecompositionError): + pass + + +@declare_custom_block(name='TreeBlock') +class TreeBlockData(_BlockData): + def __init__(self, component): + _BlockData.__init__(self, component) + self._children_index = None + self._children = None + self.coupling_vars = list() + self._already_setup = False + self._is_leaf = None + self._is_root = True + + def is_root(self): + self._assert_setup() + return self._is_root + + def setup(self, children_keys, coupling_vars=None): + assert not self._already_setup + self._already_setup = True + if len(children_keys) == 0: + self._is_leaf = True + else: + self._is_leaf = False + del self._children_index + del self._children + self._children_index = pe.Set(initialize=children_keys) + self._children = TreeBlock(self._children_index) + if coupling_vars is None: + self.coupling_vars = list() + else: + self.coupling_vars = list(coupling_vars) + for key in children_keys: + child = self.children[key] + child._is_root = False + + def _assert_setup(self): + if not self._already_setup: + raise TreeBlockError( + 'The TreeBlock has not been setup yet. Please call the setup method.' + ) + + def is_leaf(self): + self._assert_setup() + return self._is_leaf + + @property + def children(self): + self._assert_setup() + if self.is_leaf(): + raise TreeBlockError( + 'Leaf TreeBlocks do not have children. Please check the is_leaf method' + ) + return self._children + + def _num_stages(self): + self._assert_setup() + num_stages = 1 + if not self.is_leaf(): + num_stages += max([child._num_stages() for child in self.children.values()]) + return num_stages + + def num_stages(self): + if not self._is_root: + raise TreeBlockError( + 'The num_stages method can only be called from the root TreeBlock' + ) + return self._num_stages() + + @staticmethod + def _stage_blocks(children, count, stage): + if count == stage: + for child in children.values(): + yield child + else: + for child in children.values(): + if not child.is_leaf(): + for b in TreeBlockData._stage_blocks( + child.children, count + 1, stage + ): + yield b + + def stage_blocks(self, stage, active=None): + self._assert_setup() + if not self._is_root: + raise TreeBlockError( + 'The num_stages method can only be called from the root TreeBlock' + ) + if stage == 0: + if (active and self.active) or (not active): + yield self + elif not self.is_leaf(): + for b in self._stage_blocks(self.children, 1, stage): + if (active and b.active) or (not active): + yield b + + def get_block_stage(self, block): + self._assert_setup() + if not self._is_root: + raise TreeBlockError( + 'The get_block_stage method can only be called from the root TreeBlock.' + ) + for stage_ndx in range(self.num_stages()): + stage_blocks = OrderedSet(self.stage_blocks(stage_ndx)) + if block in stage_blocks: + return stage_ndx + return None + + +class _Node(object): + def __init__(self, comp): + self.comp = comp + + def is_var(self): + return False + + def is_con(self): + return False + + def is_rel(self): + return False + + def __repr__(self): + return str(self.comp) + + def __str__(self): + return str(self.comp) + + def __eq__(self, other): + if isinstance(other, _Node): + return self.comp is other.comp + return False + + def __hash__(self): + return hash(id(self.comp)) + + +class _VarNode(_Node): + def is_var(self): + return True + + +class _ConNode(_Node): + def is_con(self): + return True + + +class _RelNode(_Node): + def is_rel(self): + return True + + +class _Edge(object): + def __init__(self, node1: _VarNode, node2: _Node): + assert node1.is_var() + self.node1 = node1 + self.node2 = node2 + + def __str__(self): + s = 'Edge from {0} to {1}'.format(str(self.node1), str(self.node2)) + return s + + +class _Tree(object): + def __init__(self, children, coupling_vars): + """ + Parameters + ---------- + children: Sequence[networkx.Graph] + coupling_vars: Sequence[_VarNode] + """ + self.children: MutableSet[Union[_Tree, networkx.Graph]] = OrderedSet() + self.coupling_vars: MutableSet[_VarNode] = OrderedSet() + if children is not None: + self.children.update(children) + if coupling_vars is not None: + self.coupling_vars.update(coupling_vars) + + def build_pyomo_model(self, block): + """ + Parameters + ---------- + block: TreeBlockData + empty TreeBlock + """ + block.setup( + children_keys=list(range(len(self.children))), + coupling_vars=[i.comp for i in self.coupling_vars], + ) + + for i, child in enumerate(self.children): + if isinstance(child, _Tree): + child.build_pyomo_model(block=block.children[i]) + elif isinstance(child, networkx.Graph): + block.children[i].setup(children_keys=list(), coupling_vars=list()) + build_pyomo_model_from_graph(graph=child, block=block.children[i]) + else: + raise ValueError('Unexpected child type: {0}'.format(str(type(child)))) + + def to_string(self, prefix=''): + s = '' + s += f'{prefix}# Edges: {len(self.coupling_vars)}\n' + for _child in self.children: + if isinstance(_child, _Tree): + s += _child.to_string(prefix=prefix + ' ') + else: + s += f'{prefix} Leaf: # NNZ: {_child.number_of_edges()}\n' + return s + + def log(self, prefix=''): + logger.debug(self.to_string(prefix=prefix)) + + def __str__(self): + return self.to_string() + + +def _is_dominated(ndx, num_cuts, balance, num_cuts_array, balance_array): + cut_diff = (num_cuts - num_cuts_array) >= 0 + balance_diff = (abs(balance - 0.5) - abs(balance_array - 0.5)) >= 0 + cut_diff[ndx] = False + balance_diff[ndx] = False + return np.any(cut_diff & balance_diff) + + +def _networkx_to_adjacency_list(graph: networkx.Graph): + adj_list = list() + node_to_ndx_map = dict() + for ndx, node in enumerate(graph.nodes): + node_to_ndx_map[node] = ndx + + for ndx, node in enumerate(graph.nodes): + adj_list.append(list()) + for other_node in graph.adj[node].keys(): + other_ndx = node_to_ndx_map[other_node] + adj_list[ndx].append(other_ndx) + + return adj_list + + +def choose_metis_partition(graph, max_size_diff_trials, seed_trials): + """ + Parameters + ---------- + graph: networkx.Graph + max_size_diff_trials: list of float + seed_trials: list of int + + Returns + ------- + max_size_diff_selected: float + seed_selected: float + """ + if not metis_available: + raise ImportError( + 'Cannot perform graph partitioning without metis. Please install metis (including the python bindings).' + ) + cut_list = list() + for _max_size_diff in max_size_diff_trials: + for _seed in seed_trials: + if _seed is None: + edgecuts, parts = metis.part_graph( + _networkx_to_adjacency_list(graph), + nparts=2, + ubvec=[1 + _max_size_diff], + ) + else: + edgecuts, parts = metis.part_graph( + _networkx_to_adjacency_list(graph), + nparts=2, + ubvec=[1 + _max_size_diff], + seed=_seed, + ) + cut_list.append( + (edgecuts, sum(parts) / graph.number_of_nodes(), _max_size_diff, _seed) + ) + cut_list.sort(key=lambda i: i[0]) + + ############################ + # get the "pareto front" obtained with metis + ############################ + num_cuts_array = np.array([i[0] for i in cut_list]) + balance_array = np.array([i[1] for i in cut_list]) + + pareto_list = list() + for ndx, partition in enumerate(cut_list): + num_cuts = partition[0] + balance = partition[1] + if not _is_dominated(ndx, num_cuts, balance, num_cuts_array, balance_array): + pareto_list.append(partition) + if len(pareto_list) == 0: + pareto_list.append(cut_list[0]) + + selection = 0 + chosen_partition = pareto_list[selection] + max_size_diff_selected = chosen_partition[2] + seed_selected = chosen_partition[3] + return max_size_diff_selected, seed_selected + + +def evaluate_partition(original_graph, tree): + """ + Parameters + ---------- + original_graph: networkx.Graph + tree: _Tree + """ + original_graph_nnz = original_graph.number_of_edges() + original_graph_n_vars_to_tighten = len( + collect_vars_to_tighten_from_graph(graph=original_graph) + ) + original_obbt_nnz = original_graph_nnz * original_graph_n_vars_to_tighten + + tree_obbt_nnz = 0 + tree_nnz = 0 + assert len(tree.children) == 2 + for child in tree.children: + assert isinstance(child, networkx.Graph) + child_nnz = child.number_of_edges() + tree_nnz += child_nnz + child_n_vars_to_tighten = len(collect_vars_to_tighten_from_graph(graph=child)) + tree_obbt_nnz += child_nnz * child_n_vars_to_tighten + tree_obbt_nnz += tree_nnz * len(tree.coupling_vars) + if tree_obbt_nnz > 0: + partitioning_ratio = original_obbt_nnz / tree_obbt_nnz + else: + partitioning_ratio = None + return partitioning_ratio + + +def _refine_partition( + graph: nx.Graph, + model: _BlockData, + removed_edges: Sequence[_Edge], + graph_a_nodes: MutableSet[_Node], + graph_b_nodes: MutableSet[_Node], +): + con_count = defaultdict(int) + for edge in removed_edges: + n1, n2 = edge.node1, edge.node2 + assert n1.is_var() != n2.is_var() # xor + if n2.is_var(): + n1, n2 = n2, n1 + if n2.is_con(): # n2 might be a rel + con_count[n2.comp] += 1 + + for c, count in con_count.items(): + if count < 3: + continue + + new_body = flatten_expr(c.body) + + if type(new_body) not in { + numeric_expr.SumExpression, + numeric_expr.LinearExpression, + }: + logger.info( + f'Constraint {str(c)} is contributing to {count} removed ' + f'edges, but we cannot split the constraint because the ' + f'body is not a SumExpression.' + ) + continue + + graph_a_args = list() + graph_b_args = list() + correct_structure = True + for arg in new_body.args: + graph_a_arg_vars = ComponentSet() + graph_b_arg_vars = ComponentSet() + for v in identify_variables(arg, include_fixed=False): + v_node = _VarNode(v) + assert v_node in graph_a_nodes or v_node in graph_b_nodes + if v_node in graph_a_nodes: + graph_a_arg_vars.add(v) + else: + graph_b_arg_vars.add(v) + if len(graph_a_arg_vars) > 0 and len(graph_b_arg_vars) > 0: + correct_structure = False + break + if len(graph_a_arg_vars) > 0: + graph_a_args.append(arg) + elif len(graph_b_arg_vars) > 0: + graph_b_args.append(arg) + else: + graph_a_args.append(arg) + + if not correct_structure: + logger.info( + f'Constraint {str(c)} is contributing to {count} removed ' + f'edges, but we cannot split the constraint because some of ' + f'the terms in the SumExpression contain variables from both ' + f'partitions.' + ) + continue + + graph_a_var = model.aux_vars.add() + graph_b_var = model.aux_vars.add() + model.vars.extend([graph_a_var, graph_b_var]) + graph.remove_node(_ConNode(c)) + graph.add_node(_VarNode(graph_a_var)) + graph.add_node(_VarNode(graph_b_var)) + + new_cons = list() + for e in [sum(graph_a_args) - graph_a_var, sum(graph_b_args) - graph_b_var]: + repn = generate_standard_repn(e) + if repn.is_linear(): + con_list = model.linear.cons + else: + con_list = model.nonlinear.cons + if c.lb is not None and c.ub is not None: + new_c = con_list.add(e == 0) + elif c.ub is not None: + new_c = con_list.add(e <= 0) + else: + new_c = con_list.add(e >= 0) + new_cons.append(new_c) + c.set_value((c.lb, graph_a_var + graph_b_var, c.ub)) + new_cons.append(c) + for new_c in new_cons: + graph.add_node(_ConNode(new_c)) + for v in identify_variables(new_c.body, include_fixed=False): + graph.add_edge(_VarNode(v), _ConNode(new_c)) + + # update removed_edges + new_removed_edges = list() + for e in removed_edges: + assert e.node1.is_var() + if e.node2.comp is not c: + new_removed_edges.append(e) + + new_removed_edges.append(_Edge(_VarNode(graph_a_var), _ConNode(c))) + removed_edges = new_removed_edges + + # update graph_a_nodes and graph_b_nodes + graph_a_nodes.discard(_ConNode(c)) + graph_b_nodes.discard(_ConNode(c)) + graph_a_nodes.add(_VarNode(graph_a_var)) + graph_b_nodes.add(_VarNode(graph_b_var)) + graph_a_nodes.add(_ConNode(new_cons[0])) + graph_b_nodes.add(_ConNode(new_cons[1])) + graph_b_nodes.add(_ConNode(c)) + + return removed_edges + + +def split_metis(graph, model): + """ + Parameters + ---------- + graph: networkx.Graph + model: _BlockData + + Returns + ------- + tree: _Tree + """ + if not metis_available: + raise ImportError( + 'Cannot perform graph partitioning without metis. Please install metis (including the python bindings).' + ) + max_size_diff, seed = choose_metis_partition( + graph, max_size_diff_trials=[0.15], seed_trials=list(range(10)) + ) + if seed is None: + edgecuts, parts = metis.part_graph( + _networkx_to_adjacency_list(graph), nparts=2, ubvec=[1 + max_size_diff] + ) + else: + edgecuts, parts = metis.part_graph( + _networkx_to_adjacency_list(graph), + nparts=2, + ubvec=[1 + max_size_diff], + seed=seed, + ) + + graph_a_nodes = OrderedSet() + graph_b_nodes = OrderedSet() + for ndx, n in enumerate(graph.nodes()): + if parts[ndx] == 0: + graph_a_nodes.add(n) + else: + assert parts[ndx] == 1 + graph_b_nodes.add(n) + + removed_edges = list() + for n1, n2 in graph.edges(): + assert n1.is_var() != n2.is_var() # xor + if n2.is_var(): + n1, n2 = n2, n1 + if n1 in graph_a_nodes and n2 in graph_a_nodes: + continue + elif n1 in graph_b_nodes and n2 in graph_b_nodes: + continue + else: + removed_edges.append(_Edge(n1, n2)) + + removed_edges = _refine_partition( + graph=graph, + model=model, + removed_edges=removed_edges, + graph_a_nodes=graph_a_nodes, + graph_b_nodes=graph_b_nodes, + ) + + graph_a_edges = list() + graph_b_edges = list() + for n1, n2 in graph.edges(): + assert n1.is_var() != n2.is_var() # xor + if n2.is_var(): + n1, n2 = n2, n1 + if n2 in graph_a_nodes: + graph_a_edges.append((n1, n2)) + else: + assert n2 in graph_b_nodes + graph_b_edges.append((n1, n2)) + + linking_var_nodes = OrderedSet() + for e in removed_edges: + n1, n2 = e.node1, e.node2 + assert n1.is_var() != n2.is_var() # xor + if n2.is_var(): + n1, n2 = n2, n1 + linking_var_nodes.add(n1) + + graph_a = networkx.Graph() + graph_b = networkx.Graph() + + graph_a.add_nodes_from(graph_a_nodes) + graph_b.add_nodes_from(graph_b_nodes) + graph_a.add_edges_from(graph_a_edges) + graph_b.add_edges_from(graph_b_edges) + + if (graph_a.number_of_nodes() >= 0.99 * graph.number_of_nodes()) or ( + graph_b.number_of_nodes() >= 0.99 * graph.number_of_nodes() + ): + raise DecompositionError('Partition is extremely unbalanced') + + tree = _Tree(children=[graph_a, graph_b], coupling_vars=linking_var_nodes) + + partitioning_ratio = evaluate_partition(original_graph=graph, tree=tree) + + return tree, partitioning_ratio + + +def convert_pyomo_model_to_bipartite_graph(m: _BlockData): + """ + Parameters + ---------- + m: _BlockData + + Returns + ------- + graph: networkx.Graph + """ + graph = networkx.Graph() + var_map = pe.ComponentMap() + + for b in relaxation_data_objects(m, descend_into=True, active=True): + node2 = _RelNode(b) + for v in list(b.get_rhs_vars()) + [b.get_aux_var()]: + if pe.is_fixed(v): + continue + if v not in var_map: + var_map[v] = _VarNode(v) + node1 = var_map[v] + graph.add_edge(node1, node2) + + for c in nonrelaxation_component_data_objects( + m, pe.Constraint, active=True, descend_into=True + ): + node2 = _ConNode(c) + for v in identify_variables(c.body, include_fixed=False): + if v not in var_map: + var_map[v] = _VarNode(v) + node1 = var_map[v] + graph.add_edge(node1, node2) + + return graph + + +def build_pyomo_model_from_graph(graph, block): + """ + Parameters + ---------- + graph: networkx.Graph + block: pe.Block + + Returns + ------- + component_map: pe.ComponentMap + """ + vars = list() + cons = list() + rels = list() + for node in graph.nodes(): + if node.is_var(): + vars.append(node) + elif node.is_con(): + cons.append(node) + else: + assert node.is_rel() + rels.append(node) + + assert len(vars) == len(set(vars)) + assert len(cons) == len(set(cons)) + assert len(rels) == len(set(rels)) + + block.cons = pe.ConstraintList() + block.rels = pe.Block() + + for con_node in cons: + con = con_node.comp + block.cons.add((con.lb, con.body, con.ub)) + + for ndx, rel in enumerate(rels): + new_rel = copy_relaxation_with_local_data(rel.comp) + setattr(block.rels, f'rel{ndx}', new_rel) + new_rel.rebuild() + + +def num_cons_in_graph(graph, include_rels=True): + res = 0 + + if include_rels: + for n in graph.nodes(): + if n.is_con() or n.is_rel(): + res += 1 + else: + for n in graph.nodes(): + if n.is_con(): + res += 1 + + return res + + +class DecompositionStatus(enum.Enum): + normal = 0 # the model was successfullay decomposed at least once and no exception was raised + error = 1 # an exception was raised + bad_ratio = 2 # the model could not be decomposed at all because the min_partition_ratio was not satisfied + problem_too_small = 3 # the model could not be decomposed at all because the number of jacobian nonzeros in the original problem was less than max_leaf_nnz + + +def compute_partition_ratio( + original_model: _BlockData, decomposed_model: TreeBlockData +): + graph = convert_pyomo_model_to_bipartite_graph(original_model) + pr_numerator = graph.number_of_edges() * len( + collect_vars_to_tighten_from_graph(graph) + ) + + pr_denominator = 0 + vars_to_tighten_by_block = collect_vars_to_tighten_by_block(decomposed_model, 'dbt') + for block, vars_to_tighten in vars_to_tighten_by_block.items(): + pr_denominator += ( + len(vars_to_tighten) + * convert_pyomo_model_to_bipartite_graph(block).number_of_edges() + ) + pr = pr_numerator / pr_denominator + return pr + + +def _reformulate_objective(model): + current_obj = get_objective(model) + if current_obj is None: + return None + if not current_obj.expr.is_variable_type(): + obj_var = model.aux_vars.add() + lb, ub = compute_bounds_on_expr(current_obj.expr) + obj_var.setlb(lb) + obj_var.setub(ub) + current_obj_expr = current_obj.expr + current_obj_sense = current_obj.sense + current_obj.parent_block().del_component(current_obj) + new_objective = pe.Objective(expr=obj_var) + new_obj_name = unique_component_name(model, 'objective') + model.add_component(new_obj_name, new_objective) + if current_obj_sense == pe.minimize: + obj_con = pe.Constraint(expr=current_obj_expr <= obj_var) + else: + obj_con = pe.Constraint(expr=current_obj_expr >= obj_var) + new_objective.sense = pe.maximize + obj_con_name = unique_component_name(model, 'obj_con') + model.add_component(obj_con_name, obj_con) + + +def _decompose_model( + model: _BlockData, + max_leaf_nnz: Optional[int] = None, + min_partition_ratio: float = 0, + limit_num_stages: bool = True, +): + """ + Parameters + ---------- + model: _BlockData + The model to decompose + max_leaf_nnz: int + maximum number nonzeros in the constraint jacobian of the leaves + min_partition_ratio: float + If the partition ration is less than min_partition_ratio, the partition is not + accepted and partitioning stops. This value should be between 1 and 2. + limit_num_stages: bool + If True, partitioning will stop before the number of stages produced exceeds + round(math.log10(number of nonzeros in the constraint jacobian of model)) + + Returns + ------- + new_model: TreeBlockData + The decomposed model + component_map: pe.ComponentMap + A ComponentMap mapping variables and constraints in model to those in new_model + termination_reason: DecompositionStatus + An enum member from DecompositionStatus + """ + new_model = TreeBlock(concrete=True) + new_model.aux_vars = pe.VarList() + object.__setattr__(model, 'aux_vars', new_model.aux_vars) + + # by reformulating the objective, we can make better use of the incumbent when + # doing OBBT + _reformulate_objective(model) + + graph = convert_pyomo_model_to_bipartite_graph(model) + logger.debug('converted pyomo model to bipartite graph') + original_nnz = graph.number_of_edges() + if limit_num_stages: + max_stages = round(math.log10(original_nnz)) + else: + max_stages = math.inf + logger.debug('NNZ in original graph: {0}'.format(original_nnz)) + logger.debug('maximum number of stages: {0}'.format(max_stages)) + if max_leaf_nnz is None: + max_leaf_nnz = 0.1 * original_nnz + + if original_nnz <= max_leaf_nnz or num_cons_in_graph(graph) <= 1: + if original_nnz <= max_leaf_nnz: + logger.debug('too few NNZ in original graph; not decomposing') + else: + logger.debug('Cannot decompose graph with less than 2 constraints.') + new_model.setup(children_keys=list(), coupling_vars=list()) + build_pyomo_model_from_graph(graph=graph, block=new_model) + termination_reason = DecompositionStatus.problem_too_small + logger.debug('done building pyomo model from graph') + else: + root_tree, partitioning_ratio = split_metis(graph=graph, model=model) + logger.debug( + 'partitioned original tree; partitioning ratio: {ratio}'.format( + ratio=partitioning_ratio + ) + ) + if min_partition_ratio > 0 and partitioning_ratio < min_partition_ratio: + logger.debug('obtained bad partitioning ratio; abandoning partition') + new_model.setup(children_keys=list(), coupling_vars=list()) + build_pyomo_model_from_graph(graph=graph, block=new_model) + termination_reason = DecompositionStatus.bad_ratio + logger.debug('done building pyomo model from graph') + else: + parent = root_tree + + termination_reason = DecompositionStatus.normal + needs_split = list() + for child in parent.children: + logger.debug( + 'number of NNZ in child: {0}'.format(child.number_of_edges()) + ) + if ( + child.number_of_edges() > max_leaf_nnz + and num_cons_in_graph(child) > 1 + ): + needs_split.append((child, parent, 1)) + + while len(needs_split) > 0: + logger.debug('needs_split: {0}'.format(str(needs_split))) + _graph, _parent, _stage = needs_split.pop() + try: + if _stage + 1 >= max_stages: + logger.debug( + f'stage {_stage}: not partitiong graph with ' + f'{_graph.number_of_edges()} NNZ due to the max ' + f'stages rule;' + ) + continue + logger.debug( + f'stage {_stage}: partitioning graph with ' + f'{_graph.number_of_edges()} NNZ' + ) + sub_tree, partitioning_ratio = split_metis( + graph=_graph, model=model + ) + logger.debug( + 'partitioning ratio: {ratio}'.format(ratio=partitioning_ratio) + ) + if ( + min_partition_ratio <= 0 + or partitioning_ratio > min_partition_ratio + ): + logger.debug('partitioned {0}'.format(str(_graph))) + _parent.children.discard(_graph) + _parent.children.add(sub_tree) + + for child in sub_tree.children: + logger.debug( + 'number of NNZ in child: {0}'.format( + child.number_of_edges() + ) + ) + if ( + child.number_of_edges() > max_leaf_nnz + and num_cons_in_graph(child) > 1 + ): + needs_split.append((child, sub_tree, _stage + 1)) + else: + logger.debug( + 'obtained bad partitioning ratio; abandoning partition' + ) + except DecompositionError: + termination_reason = DecompositionStatus.error + logger.error( + 'failed to partition graph with {0} NNZ'.format( + _graph.number_of_edges() + ) + ) + + logger.debug('Tree Info:') + root_tree.log() + + root_tree.build_pyomo_model(block=new_model) + logger.debug('done building pyomo model from tree') + + obj = get_objective(model) + if obj is not None: + new_model.obj = pe.Objective(expr=obj.expr, sense=obj.sense) + logger.debug('done adding objective to new model') + else: + logger.debug('No objective was found to add to the new model') + + return new_model, termination_reason + + +def decompose_model( + model: _BlockData, + max_leaf_nnz: Optional[int] = None, + min_partition_ratio: float = 0, + limit_num_stages: bool = True, +): + """ + Parameters + ---------- + model: _BlockData + The model to decompose + max_leaf_nnz: int + maximum number nonzeros in the constraint jacobian of the leaves + min_partition_ratio: float + If the partition ration is less than min_partition_ratio, the partition is not + accepted and partitioning stops. This value should be between 1 and 2. + limit_num_stages: bool + If True, partitioning will stop before the number of stages produced exceeds + round(math.log10(number of nonzeros in the constraint jacobian of model)) + + Returns + ------- + new_model: TreeBlockData + The decomposed model + termination_reason: DecompositionStatus + An enum member from DecompositionStatus + """ + # we have to clone the model because we modify it in _refine_partition + model = clone_shallow_active_flat(model)[0] + + tmp = _decompose_model( + model, + max_leaf_nnz=max_leaf_nnz, + min_partition_ratio=min_partition_ratio, + limit_num_stages=limit_num_stages, + ) + tree_model, termination_reason = tmp + + return tree_model, termination_reason + + +def collect_vars_to_tighten_from_graph(graph): + vars_to_tighten = ComponentSet() + + for n in graph.nodes(): + if n.is_rel(): + rel: BaseRelaxationData = n.comp + if rel.is_rhs_convex() and rel.relaxation_side == RelaxationSide.UNDER: + continue + if rel.is_rhs_concave() and rel.relaxation_side == RelaxationSide.OVER: + continue + vars_to_tighten.update(rel.get_rhs_vars()) + elif n.is_var(): + v = n.comp + if v.is_binary() or v.is_integer(): + vars_to_tighten.add(v) + + return vars_to_tighten + + +def collect_vars_to_tighten(block): + graph = convert_pyomo_model_to_bipartite_graph(block) + vars_to_tighten = collect_vars_to_tighten_from_graph(graph=graph) + return vars_to_tighten + + +def collect_vars_to_tighten_by_block(m, method): + """ + Parameters + ---------- + m: TreeBlockData + method: str + 'full_space', 'dbt', or 'leaves' + + Returns + ------- + vars_to_tighten_by_block: dict + maps Block to ComponentSet of Var + """ + assert method in {'full_space', 'dbt', 'leaves'} + + vars_to_tighten_by_block = dict() + if method == 'full_space': + vars_to_tighten_by_block[m] = collect_vars_to_tighten(m) + return vars_to_tighten_by_block + + assert isinstance(m, TreeBlockData) + + all_vars_to_account_for = collect_vars_to_tighten(m) + + for stage in range(m.num_stages()): + for block in m.stage_blocks(stage, active=True): + if block.is_leaf(): + vars_to_tighten_by_block[block] = collect_vars_to_tighten(block=block) + elif method == 'leaves': + vars_to_tighten_by_block[block] = ComponentSet() + elif method == 'full_space': + vars_to_tighten_by_block[block] = ComponentSet() + else: + vars_to_tighten_by_block[block] = ComponentSet(block.coupling_vars) + + for block, vars_to_tighten in vars_to_tighten_by_block.items(): + for v in vars_to_tighten: + all_vars_to_account_for.discard(v) + + if len(all_vars_to_account_for) != 0: + raise RuntimeError( + 'There are variables that need tightened that are unaccounted for!' + ) + + return vars_to_tighten_by_block + + +class OBBTMethod(enum.Enum): + FULL_SPACE = 1 + DECOMPOSED = 2 + LEAVES = 3 + + +class FilterMethod(enum.Enum): + NONE = 1 + AGGRESSIVE = 2 + + +class DBTInfo(object): + """ + Attributes + ---------- + num_coupling_vars_to_tighten: int + The total number of coupling variables that need tightened. Note that this includes + coupling variables that get filtered. If you subtract num_coupling_vars_attempted + and num_coupling_vars_filtered from num_coupling_vars_to_tighten, you should get + the number of coupling variables that were not tightened due to a time limit. + num_coupling_vars_attempted: int + The number of coupling variables for which tightening was attempted. + num_coupling_vars_successful: int + The number of coupling variables for which tightening was attempted and the solver + terminated optimally. + num_coupling_vars_filtered: int + The number of coupling vars that did not need to be tightened (identified by filtering). + num_vars_to_tighten: int + The total number of nonlinear and discrete variables that need tightened. Note that + this includes variables that get filtered. If you subtract num_vars_attempted and + num_vars_filtered from num_vars_to_tighten, you should get the number of nonlinear + and discrete variables that were not tightened due to a time limit. + num_vars_attempted: int + The number of variables for which tightening was attempted. + num_vars_successful: int + The number of variables for which tightening was attempted and the solver + terminated optimally. + num_vars_filtered: int + The number of vars that did not need to be tightened (identified by filtering). + """ + + def __init__(self): + self.num_coupling_vars_to_tighten = None + self.num_coupling_vars_attempted = None + self.num_coupling_vars_successful = None + self.num_coupling_vars_filtered = None + self.num_vars_to_tighten = None + self.num_vars_attempted = None + self.num_vars_successful = None + self.num_vars_filtered = None + + def __str__(self): + s = f'num_coupling_vars_to_tighten: {self.num_coupling_vars_to_tighten}\n' + s += f'num_coupling_vars_attempted: {self.num_coupling_vars_attempted}\n' + s += f'num_coupling_vars_successful: {self.num_coupling_vars_successful}\n' + s += f'num_coupling_vars_filtered: {self.num_coupling_vars_filtered}\n' + s += f'num_vars_to_tighten: {self.num_vars_to_tighten}\n' + s += f'num_vars_attempted: {self.num_vars_attempted}\n' + s += f'num_vars_successful: {self.num_vars_successful}\n' + s += f'num_vars_filtered: {self.num_vars_filtered}\n' + return s + + +def _update_var_bounds( + varlist, + new_lower_bounds, + new_upper_bounds, + feasibility_tol, + safety_tol, + max_acceptable_bound, +): + for ndx, v in enumerate(varlist): + new_lb = new_lower_bounds[ndx] + new_ub = new_upper_bounds[ndx] + orig_lb = v.lb + orig_ub = v.ub + + if new_lb is None: + new_lb = -math.inf + if new_ub is None: + new_ub = math.inf + if orig_lb is None: + orig_lb = -math.inf + if orig_ub is None: + orig_ub = math.inf + + rel_lb_safety = safety_tol * abs(new_lb) + rel_ub_safety = safety_tol * abs(new_ub) + new_lb -= max(safety_tol, rel_lb_safety) + new_ub += max(safety_tol, rel_ub_safety) + + if new_lb < -max_acceptable_bound: + new_lb = -math.inf + if new_ub > max_acceptable_bound: + new_ub = math.inf + + if new_lb > new_ub: + msg = 'variable ub is less than lb; var: {0}; lb: {1}; ub: {2}'.format( + str(v), new_lb, new_ub + ) + if new_lb > new_ub + feasibility_tol: + raise ValueError(msg) + else: + logger.warning( + msg + + '; decreasing lb and increasing ub by {0}'.format(feasibility_tol) + ) + warnings.warn(msg) + new_lb -= feasibility_tol + new_ub += feasibility_tol + + if new_lb < orig_lb: + new_lb = orig_lb + if new_ub > orig_ub: + new_ub = orig_ub + + if new_lb > -math.inf: + v.setlb(new_lb) + if new_ub < math.inf: + v.setub(new_ub) + + +def perform_dbt( + relaxation, + solver, + obbt_method=OBBTMethod.DECOMPOSED, + filter_method=FilterMethod.AGGRESSIVE, + time_limit=math.inf, + objective_bound=None, + with_progress_bar=False, + parallel=False, + vars_to_tighten_by_block=None, + feasibility_tol=0, + safety_tol=0, + max_acceptable_bound=math.inf, + update_relaxations_between_stages=True, +): + """This function performs optimization-based bounds tightening (OBBT) with a decomposition scheme. + + Parameters + ---------- + relaxation: dbt.decomp.decompose.TreeBlockData + The relaxation to use for OBBT. + solver: pyomo solver object + The solver to use for the OBBT problems. + obbt_method: OBBTMethod + An enum member from OBBTMethod. The default is OBBTMethod.DECOMPOSED. If obbt_method + is OBBTMethod.DECOMPOSED, then only the coupling variables in the linking constraints + will be tightened with non-leaf blocks in relaxation. The nonlinear and discrete + variables will only be tightened with the leaf blocks in relaxation. See the + documentation on TreeBlockData for more details on leaf blocks. If the method is + OBBTMethod.FULL_SPACE, then all of the nonlinear and discrete variables will + be tightened with the root block from relaxation. If the method is + OBBTMethod.LEAVES, then the nonlinear and discrete variables will be tightened + with the leaf blocks from relaxation (none of the coupling variables will be + tightened). + filter_method: FilterMethod + An enum member from FilterMethod. The default is FilterMethod.AGGRESSIVE. If + filter_method is FilterMethod.AGGRESSIVE, then aggressive filtering will be + performed at every stage of OBBT using the + coramin.domain_reduction.filters.aggressive_filter function which is based on + + Gleixner, Ambros M., et al. "Three enhancements for + optimization-based bound tightening." Journal of Global + Optimization 67.4 (2017): 731-757. + + If filter_method is FilterMethod.NONE, then no filtering will be performed. + time_limit: float + If the time spent in this function exceeds time_limit, OBBT will be terminated + early. + objective_bound: float + A lower or upper bound on the objective. If this is not None, then a constraint will be added to the + bounds tightening problems constraining the objective to be less than/greater than objective_bound. + with_progress_bar: bool + parallel: bool + If True, then OBBT will automatically be performed in parallel if mpirun or mpiexec was used; + If False, then OBBT will not run in parallel even if mpirun or mpiexec was used; + vars_to_tighten_by_block: dict + Dictionary mapping TreeBlockData to ComponentSet. This dictionary indicates which variables + should be tightened with which parts of the TreeBlockData. If None is passed (default=None), + then, the function collect_vars_to_tighten_by_block is used to get the dict. + feasibility_tol: float + If the lower bound for a computed variable is larger than the computed upper bound by more than + feasibility_tol, then an error is raised. If the computed lower bound is larger than the computed + upper bound, but by less than feasibility_tol, then the computed lower bound is decreased by + feasibility tol (but will not be set lower than the original lower bound) and the computed upper + bound is increased by feasibility_tol (but will not be set higher than the original upper bound). + safety_tol: float + Computed lower bounds will be decreased by max(safety_tol, safety_tol*abs(new_lb) and + computed upper bounds will be increased by max(safety_tol, safety_tol*abs(new_ub) where + new_lb and new_ub are the bounds computed from OBBT/DBT. The purpose of this is to + account for numerical error in the solution of the OBBT problems and to avoid cutting + off valid portions of the feasible region. + max_acceptable_bound: float + If the upper bound computed for a variable is larger than max_acceptable_bound, then the + computed bound will be rejected. If the lower bound computed for a variable is less than + -max_acceptable_bound, then the computed bound will be rejected. + update_relaxations_between_stages: bool + This is meant for unit testing only and should not be modified + + Returns + ------- + dbt_info: DBTInfo + + """ + t0 = time.time() + + if not isinstance(relaxation, TreeBlockData): + raise ValueError('relaxation must be an instance of dbt.decomp.TreeBlockData.') + if obbt_method not in OBBTMethod: + raise ValueError('obbt_method must be a member of OBBTMethod.') + if filter_method not in FilterMethod: + raise ValueError('filter_method must a member of FilterMethod.') + if isinstance(solver, PersistentSolver): + using_persistent_solver = True + else: + using_persistent_solver = False + + dbt_info = DBTInfo() + dbt_info.num_coupling_vars_to_tighten = 0 + dbt_info.num_coupling_vars_attempted = 0 + dbt_info.num_coupling_vars_successful = 0 + dbt_info.num_coupling_vars_filtered = 0 + dbt_info.num_vars_to_tighten = 0 + dbt_info.num_vars_attempted = 0 + dbt_info.num_vars_successful = 0 + dbt_info.num_vars_filtered = 0 + + assert obbt_method in OBBTMethod + if vars_to_tighten_by_block is None: + if obbt_method == OBBTMethod.DECOMPOSED: + _method = 'dbt' + elif obbt_method == OBBTMethod.FULL_SPACE: + _method = 'full_space' + else: + _method = 'leaves' + vars_to_tighten_by_block = collect_vars_to_tighten_by_block(relaxation, _method) + + var_to_relaxation_map = pe.ComponentMap() + for r in relaxation_data_objects(relaxation, descend_into=True, active=True): + for v in r.get_rhs_vars(): + if v not in var_to_relaxation_map: + var_to_relaxation_map[v] = list() + var_to_relaxation_map[v].append(r) + + num_stages = relaxation.num_stages() + + for stage in range(num_stages): + stage_blocks = list(relaxation.stage_blocks(stage)) + for block in stage_blocks: + if block in vars_to_tighten_by_block: + vars_to_tighten = vars_to_tighten_by_block[block] + else: + vars_to_tighten = list() + if obbt_method == OBBTMethod.FULL_SPACE or block.is_leaf(): + dbt_info.num_vars_to_tighten += 2 * len(vars_to_tighten) + else: + dbt_info.num_coupling_vars_to_tighten += 2 * len(vars_to_tighten) + + if obbt_method == OBBTMethod.FULL_SPACE: + all_vars_to_tighten = ComponentSet() + for block, block_vars_to_tighten in vars_to_tighten_by_block.items(): + all_vars_to_tighten.update(block_vars_to_tighten) + if filter_method == FilterMethod.AGGRESSIVE: + logger.debug('starting full space filter') + res = aggressive_filter( + candidate_variables=all_vars_to_tighten, + relaxation=relaxation, + solver=solver, + tolerance=1e-4, + objective_bound=objective_bound, + ) + full_space_lb_vars, full_space_ub_vars = res + logger.debug('finished full space filter') + else: + full_space_lb_vars = all_vars_to_tighten + full_space_ub_vars = all_vars_to_tighten + else: + full_space_lb_vars = None + full_space_ub_vars = None + + for stage in range(num_stages): + logger.info(f'Performing DBT on stage {stage+1} of {num_stages}') + if time.time() - t0 >= time_limit: + break + + stage_blocks = list(relaxation.stage_blocks(stage)) + logger.debug( + 'DBT stage {0} of {1} with {1} blocks'.format( + stage, num_stages, len(stage_blocks) + ) + ) + + for block_ndx, block in enumerate(stage_blocks): + logger.info( + f'performing DBT on block {block_ndx+1} of {len(stage_blocks)} in stage {stage+1}' + ) + if time.time() - t0 >= time_limit: + break + + if obbt_method == OBBTMethod.LEAVES and not block.is_leaf(): + continue + if obbt_method == OBBTMethod.FULL_SPACE and not block.is_root(): + continue + if obbt_method == OBBTMethod.FULL_SPACE: + block_to_tighten_with = relaxation + _ub = objective_bound + else: + block_to_tighten_with = block + if stage == 0: + _ub = objective_bound + else: + _ub = None + + if block in vars_to_tighten_by_block: + vars_to_tighten = vars_to_tighten_by_block[block] + else: + vars_to_tighten = list() + + if filter_method == FilterMethod.AGGRESSIVE: + logger.debug('starting filter') + if obbt_method == OBBTMethod.FULL_SPACE: + lb_vars = ComponentSet( + [v for v in vars_to_tighten if v in full_space_lb_vars] + ) + ub_vars = ComponentSet( + [v for v in vars_to_tighten if v in full_space_ub_vars] + ) + else: + res = aggressive_filter( + candidate_variables=vars_to_tighten, + relaxation=block_to_tighten_with, + solver=solver, + tolerance=1e-4, + objective_bound=_ub, + ) + lb_vars, ub_vars = res + if block.is_leaf(): + dbt_info.num_vars_filtered += ( + 2 * len(vars_to_tighten) - len(lb_vars) - len(ub_vars) + ) + else: + dbt_info.num_coupling_vars_filtered += ( + 2 * len(vars_to_tighten) - len(lb_vars) - len(ub_vars) + ) + logger.debug('done filtering') + else: + lb_vars = list(vars_to_tighten) + ub_vars = list(vars_to_tighten) + + logger.debug( + f'performing OBBT (LB) on variables {str([str(i) for i in lb_vars])}' + ) + res = normal_obbt( + block_to_tighten_with, + solver=solver, + varlist=lb_vars, + objective_bound=_ub, + with_progress_bar=with_progress_bar, + direction='lbs', + time_limit=(time_limit - (time.time() - t0)), + update_bounds=False, + parallel=parallel, + collect_obbt_info=True, + progress_bar_string=f'DBT LBs Stage {stage+1} of {num_stages} Block {block_ndx+1} of {len(stage_blocks)}', + ) + lower, unused_upper, obbt_info = res + if block.is_leaf(): + dbt_info.num_vars_attempted += obbt_info.num_problems_attempted + dbt_info.num_vars_successful += obbt_info.num_successful_problems + else: + dbt_info.num_coupling_vars_attempted += obbt_info.num_problems_attempted + dbt_info.num_coupling_vars_successful += ( + obbt_info.num_successful_problems + ) + + logger.debug('done tightening lbs') + + logger.debug( + f'performing OBBT (UB) on variables {str([str(i) for i in ub_vars])}' + ) + res = normal_obbt( + block_to_tighten_with, + solver=solver, + varlist=ub_vars, + objective_bound=_ub, + with_progress_bar=with_progress_bar, + direction='ubs', + time_limit=(time_limit - (time.time() - t0)), + update_bounds=False, + parallel=parallel, + collect_obbt_info=True, + progress_bar_string=f'DBT UBs Stage {stage+1} of {num_stages} Block {block_ndx+1} of {len(stage_blocks)}', + ) + + unused_lower, upper, obbt_info = res + if block.is_leaf(): + dbt_info.num_vars_attempted += obbt_info.num_problems_attempted + dbt_info.num_vars_successful += obbt_info.num_successful_problems + else: + dbt_info.num_coupling_vars_attempted += obbt_info.num_problems_attempted + dbt_info.num_coupling_vars_successful += ( + obbt_info.num_successful_problems + ) + + _update_var_bounds( + varlist=lb_vars, + new_lower_bounds=lower, + new_upper_bounds=unused_upper, + feasibility_tol=feasibility_tol, + safety_tol=safety_tol, + max_acceptable_bound=max_acceptable_bound, + ) + + _update_var_bounds( + varlist=ub_vars, + new_lower_bounds=unused_lower, + new_upper_bounds=upper, + feasibility_tol=feasibility_tol, + safety_tol=safety_tol, + max_acceptable_bound=max_acceptable_bound, + ) + + if update_relaxations_between_stages: + # this is needed to ensure consistency for parallel computing; this accounts + # for side effects from the OBBT problems; in particular, if the solver ever + # rebuilds relaxations, then the processes could become out of sync without + # this code + all_tightened_vars = ComponentSet(lb_vars) + all_tightened_vars.update(ub_vars) + for v in all_tightened_vars: + if v in var_to_relaxation_map: + for r in var_to_relaxation_map[v]: + r.rebuild() + + logger.debug('done tightening ubs') + + return dbt_info + + +def perform_dbt_with_integers_relaxed( + relaxation, + solver, + obbt_method=OBBTMethod.DECOMPOSED, + filter_method=FilterMethod.AGGRESSIVE, + time_limit=math.inf, + objective_bound=None, + with_progress_bar=False, + parallel=False, + vars_to_tighten_by_block=None, + feasibility_tol=0, + integer_tol=1e-2, + safety_tol=0, + max_acceptable_bound=math.inf, +): + """ + This function performs optimization-based bounds tightening (OBBT) with a decomposition scheme. + However, all OBBT problems are solved with the binary and integer variables relaxed. + + Parameters + ---------- + relaxation: dbt.decomp.decompose.TreeBlockData + The relaxation to use for OBBT. + solver: pyomo solver object + The solver to use for the OBBT problems. + obbt_method: OBBTMethod + An enum member from OBBTMethod. The default is OBBTMethod.DECOMPOSED. If obbt_method + is OBBTMethod.DECOMPOSED, then only the coupling variables in the linking constraints + will be tightened with non-leaf blocks in relaxation. The nonlinear and discrete + variables will only be tightened with the leaf blocks in relaxation. See the + documentation on TreeBlockData for more details on leaf blocks. If the method is + OBBTMethod.FULL_SPACE, then all of the nonlinear and discrete variables will + be tightened with the root block from relaxation. If the method is + OBBTMethod.LEAVES, then the nonlinear and discrete variables will be tightened + with the leaf blocks from relaxation (none of the coupling variables will be + tightened). + filter_method: FilterMethod + An enum member from FilterMethod. The default is FilterMethod.AGGRESSIVE. If + filter_method is FilterMethod.AGGRESSIVE, then aggressive filtering will be + performed at every stage of OBBT using the + coramin.domain_reduction.filters.aggressive_filter function which is based on + + Gleixner, Ambros M., et al. "Three enhancements for + optimization-based bound tightening." Journal of Global + Optimization 67.4 (2017): 731-757. + + If filter_method is FilterMethod.NONE, then no filtering will be performed. + time_limit: float + If the time spent in this function exceeds time_limit, OBBT will be terminated + early. + objective_bound: float + A lower or upper bound on the objective. If this is not None, then a constraint will be added to the + bounds tightening problems constraining the objective to be less than/greater than objective_bound. + with_progress_bar: bool + parallel: bool + If True, then OBBT will automatically be performed in parallel if mpirun or mpiexec was used; + If False, then OBBT will not run in parallel even if mpirun or mpiexec was used; + vars_to_tighten_by_block: dict + Dictionary mapping TreeBlockData to ComponentSet. This dictionary indicates which variables + should be tightened with which parts of the TreeBlockData. If None is passed (default=None), + then, the function collect_vars_to_tighten_by_block is used to get the dict. + feasibility_tol: float + If the lower bound computed for a variable is larger than the computed upper bound by more than + feasibility_tol, then an error is raised. If the computed lower bound is larger than the computed + upper bound, but by less than feasibility_tol, then the computed lower bound is decreased by + feasibility tol (but will not be set lower than the original lower bound) and the computed upper + bound is increased by feasibility_tol (but will not be set higher than the original upper bound). + integer_tol: float + If the lower bound computed for an integer variable is greater than the largest integer less than + the computed lower bound by more than integer_tol, then the lower bound is increased to the smallest + integer greater than the computed lower bound. Similar logic holds for the upper bound. + safety_tol: float + Computed lower bounds will be decreased by max(safety_tol, safety_tol*abs(new_lb) and + computed upper bounds will be increased by max(safety_tol, safety_tol*abs(new_ub) where + new_lb and new_ub are the bounds computed from OBBT/DBT. The purpose of this is to + account for numerical error in the solution of the OBBT problems and to avoid cutting + off valid portions of the feasible region. + max_acceptable_bound: float + If the upper bound computed for a variable is larger than max_acceptable_bound, then the + computed bound will be rejected. If the lower bound computed for a variable is less than + -max_acceptable_bound, then the computed bound will be rejected. + + Returns + ------- + dbt_info: DBTInfo + """ + assert obbt_method in OBBTMethod + if vars_to_tighten_by_block is None: + if obbt_method == OBBTMethod.DECOMPOSED: + _method = 'dbt' + elif obbt_method == OBBTMethod.FULL_SPACE: + _method = 'full_space' + else: + _method = 'leaves' + vars_to_tighten_by_block = collect_vars_to_tighten_by_block(relaxation, _method) + + relaxed_binary_vars, relaxed_integer_vars = push_integers(relaxation) + + dbt_info = perform_dbt( + relaxation=relaxation, + solver=solver, + obbt_method=obbt_method, + filter_method=filter_method, + time_limit=time_limit, + objective_bound=objective_bound, + with_progress_bar=with_progress_bar, + parallel=parallel, + vars_to_tighten_by_block=vars_to_tighten_by_block, + feasibility_tol=feasibility_tol, + safety_tol=safety_tol, + max_acceptable_bound=max_acceptable_bound, + ) + + pop_integers(relaxed_binary_vars, relaxed_integer_vars) + + for v in list(relaxed_binary_vars) + list(relaxed_integer_vars): + lb = v.lb + ub = v.ub + if lb is None: + lb = -math.inf + if ub is None: + ub = math.inf + if lb > -math.inf: + lb = max(math.floor(lb), math.ceil(lb - integer_tol)) + if ub < math.inf: + ub = min(math.ceil(ub), math.floor(ub + integer_tol)) + if lb > -math.inf: + v.setlb(lb) + if ub < math.inf: + v.setub(ub) + + return dbt_info diff --git a/pyomo/contrib/coramin/domain_reduction/filters.py b/pyomo/contrib/coramin/domain_reduction/filters.py new file mode 100644 index 00000000000..4898ef067e5 --- /dev/null +++ b/pyomo/contrib/coramin/domain_reduction/filters.py @@ -0,0 +1,200 @@ +from pyomo.common.collections import ComponentSet +from pyomo.contrib.coramin.domain_reduction.obbt import _bt_prep, _bt_cleanup +import pyomo.environ as pe +from pyomo.core.expr.numeric_expr import LinearExpression +import logging +from pyomo.contrib import appsi +from pyomo.core.base.var import _GeneralVarData +from pyomo.core.base.block import _BlockData +from typing import Sequence, Optional, Union + + +logger = logging.getLogger(__name__) + + +def filter_variables_from_solution( + candidate_variables_at_relaxation_solution, tolerance=1e-6 +): + """ + This function takes a set of candidate variables for OBBT and filters out + the variables that are at their bounds in the provided solution to the + relaxation. See + + Gleixner, Ambros M., et al. "Three enhancements for + optimization-based bound tightening." Journal of Global + Optimization 67.4 (2017): 731-757. + + for details on why this works. The basic idea is that if x = xl is + feasible for the relaxation that will be used for OBBT, then + minimizing x subject to that relaxation is guaranteed to result in + an optimal solution of x* = xl. + + This function simply loops through + candidate_variables_at_relaxation_solution and specifies which + variables should be minimized and which variables should be + maximized with OBBT. + + Parameters + ---------- + candidate_variables_at_relaxation_solution: iterable of _GeneralVarData + This should be an iterable of the variables which are candidates + for OBBT. The values of the variables should be feasible for the + relaxation that would be used to perform OBBT on the variables. + tolerance: float + A float greater than or equal to zero. If the value of the variable + is within tolerance of its lower bound, then that variable is filtered + from the set of variables that should be minimized for OBBT. The same + is true for upper bounds and variables that should be maximized. + + Returns + ------- + vars_to_minimize: ComponentSet of _GeneralVarData + variables that should be considered for minimization + vars_to_maximize: ComponentSet of _GeneralVarData + variables that should be considered for maximization + """ + candidate_vars = ComponentSet(candidate_variables_at_relaxation_solution) + vars_to_minimize = ComponentSet() + vars_to_maximize = ComponentSet() + + for v in candidate_vars: + if (not v.has_lb()) or (v.value - v.lb > tolerance): + vars_to_minimize.add(v) + if (not v.has_ub()) or (v.ub - v.value > tolerance): + vars_to_maximize.add(v) + + return vars_to_minimize, vars_to_maximize + + +def aggressive_filter( + candidate_variables: Sequence[_GeneralVarData], + relaxation: _BlockData, + solver: Union[appsi.base.Solver, appsi.base.PersistentSolver], + tolerance: float = 1e-6, + objective_bound: Optional[float] = None, + max_iter: int = 10, + improvement_threshold: int = 5, +): + """ + This function takes a set of candidate variables for OBBT and filters out + the variables for which it does not make senese to perform OBBT on. See + + Gleixner, Ambros M., et al. "Three enhancements for + optimization-based bound tightening." Journal of Global + Optimization 67.4 (2017): 731-757. + + for details. The basic idea is that if x = xl is + feasible for the relaxation that will be used for OBBT, then + minimizing x subject to that relaxation is guaranteed to result in + an optimal solution of x* = xl. + + This function solves a series of optimization problems to try to + filter as many variables as possible. + + Parameters + ---------- + candidate_variables: iterable of _GeneralVarData + This should be an iterable of the variables which are candidates + for OBBT. + relaxation: Block + a convex relaxation + solver: appsi.base.Solver + tolerance: float + A float greater than or equal to zero. If the value of the variable + is within tolerance of its lower bound, then that variable is filtered + from the set of variables that should be minimized for OBBT. The same + is true for upper bounds and variables that should be maximized. + objective_bound: float + Primal bound for the objective + max_iter: int + Maximum number of iterations + improvement_threshold: int + If the number of filtered variables is less than improvement_threshold, then + the filtering is terminated + + Returns + ------- + vars_to_minimize: list of _GeneralVarData + variables that should be considered for minimization + vars_to_maximize: list of _GeneralVarData + variables that should be considered for maximization + """ + vars_to_minimize = ComponentSet(candidate_variables) + vars_to_maximize = ComponentSet(candidate_variables) + if len(candidate_variables) == 0: + return vars_to_minimize, vars_to_maximize + + tmp = _bt_prep(model=relaxation, solver=solver, objective_bound=objective_bound) + deactivated_objectives, orig_update_config, orig_config = tmp + + vars_unbounded_from_below = ComponentSet() + vars_unbounded_from_above = ComponentSet() + for v in list(vars_to_minimize): + if v.lb is None: + vars_unbounded_from_below.add(v) + vars_to_minimize.remove(v) + for v in list(vars_to_maximize): + if v.ub is None: + vars_unbounded_from_above.add(v) + vars_to_maximize.remove(v) + + for _set in [vars_to_minimize, vars_to_maximize]: + for _iter in range(max_iter): + if _set is vars_to_minimize: + obj_coefs = [1 for v in _set] + else: + obj_coefs = [-1 for v in _set] + obj_vars = list(_set) + relaxation.__filter_obj = pe.Objective( + expr=LinearExpression(linear_coefs=obj_coefs, linear_vars=obj_vars) + ) + if solver.is_persistent(): + solver.set_objective(relaxation.__filter_obj) + solver.config.load_solution = False + res = solver.solve(relaxation) + if res.termination_condition == appsi.base.TerminationCondition.optimal: + res.solution_loader.load_vars() + success = True + else: + success = False + del relaxation.__filter_obj + + if not success: + break + + num_filtered = 0 + for v in list(_set): + should_filter = False + if _set is vars_to_minimize: + if v.value - v.lb <= tolerance: + should_filter = True + else: + if v.ub - v.value <= tolerance: + should_filter = True + if should_filter: + num_filtered += 1 + _set.remove(v) + logger.debug('filtered {0} vars on iter {1}'.format(num_filtered, _iter)) + + if len(_set) == 0: + break + if num_filtered < improvement_threshold: + break + + for v in vars_unbounded_from_below: + vars_to_minimize.add(v) + for v in vars_unbounded_from_above: + vars_to_maximize.add(v) + + _bt_cleanup( + model=relaxation, + solver=solver, + vardatalist=None, + deactivated_objectives=deactivated_objectives, + orig_update_config=orig_update_config, + orig_config=orig_config, + lower_bounds=None, + upper_bounds=None, + ) + + return vars_to_minimize, vars_to_maximize diff --git a/pyomo/contrib/coramin/domain_reduction/obbt.py b/pyomo/contrib/coramin/domain_reduction/obbt.py new file mode 100644 index 00000000000..2e83acad830 --- /dev/null +++ b/pyomo/contrib/coramin/domain_reduction/obbt.py @@ -0,0 +1,593 @@ +import pyomo.environ as pyo +from pyomo.core.base.var import _GeneralVarData +from pyomo.core.base.PyomoModel import ConcreteModel +import warnings +from pyomo.common.collections import ComponentMap +from pyomo.core.kernel.objective import minimize, maximize +from pyomo.contrib import appsi +import logging +import traceback +from pyomo.common.dependencies import numpy as np +import math +import time +from typing import Union, Sequence, Optional, List +from pyomo.core.base.objective import ScalarObjective, _GeneralObjectiveData + +try: + import pyomo.contrib.coramin.utils.mpi_utils as mpiu + + mpi_available = True +except ImportError: + mpi_available = False +try: + from tqdm import tqdm +except ImportError: + pass + + +logger = logging.getLogger(__name__) + + +class OBBTInfo(object): + def __init__(self): + self.total_num_problems = None + self.num_problems_attempted = None + self.num_successful_problems = None + + +def _bt_cleanup( + model, + solver: Union[appsi.base.Solver, appsi.base.PersistentSolver], + vardatalist: Optional[List[_GeneralVarData]], + deactivated_objectives, + orig_update_config, + orig_config, + lower_bounds: Optional[Sequence[float]] = None, + upper_bounds: Optional[Sequence[float]] = None, +): + """ + Cleanup the changes made to the model during bounds tightening. + Reactivate any deactivated objectives. + Remove an objective upper bound constraint if it was added. + If lower_bounds or upper_bounds is provided, update the bounds of the variables in self.vars_to_tighten. + + Parameters + ---------- + model: pyo.ConcreteModel or pyo.Block + solver: Union[appsi.base.Solver, appsi.base.PersistentSolver] + vardatalist: List of _GeneralVarData + initial_var_values: ComponentMap + deactivated_objectives: list of _GeneralObjectiveData + orig_update_config: appsi.base.UpdateConfig + orig_config: appsi.base.SolverConfig + lower_bounds: Sequence of float + Only needed if you want to update the bounds of the variables. Should be in the same order as + self.vars_to_tighten. + upper_bounds: Sequence of float + Only needed if you want to update the bounds of the variables. Should be in the same order as + self.vars_to_tighten. + """ + if hasattr(model, '__objective_ineq'): + if solver.is_persistent(): + solver.remove_constraints([model.__objective_ineq]) + del model.__objective_ineq + + # reactivate the objectives that we deactivated + for obj in deactivated_objectives: + obj.activate() + if solver.is_persistent(): + solver.set_objective(obj) + + if lower_bounds is not None and upper_bounds is not None: + for i, v in enumerate(vardatalist): + lb = lower_bounds[i] + ub = upper_bounds[i] + v.setlb(lb) + v.setub(ub) + elif lower_bounds is not None: + for i, v in enumerate(vardatalist): + lb = lower_bounds[i] + v.setlb(lb) + elif upper_bounds is not None: + for i, v in enumerate(vardatalist): + ub = upper_bounds[i] + v.setub(ub) + if vardatalist is not None and solver.is_persistent(): + solver.update_variables(vardatalist) + + if solver.is_persistent(): + solver.update_config.check_for_new_or_removed_constraints = ( + orig_update_config.check_for_new_or_removed_constraints + ) + solver.update_config.check_for_new_or_removed_vars = ( + orig_update_config.check_for_new_or_removed_vars + ) + solver.update_config.check_for_new_or_removed_params = ( + orig_update_config.check_for_new_or_removed_params + ) + solver.update_config.check_for_new_objective = ( + orig_update_config.check_for_new_objective + ) + solver.update_config.update_constraints = orig_update_config.update_constraints + solver.update_config.update_vars = orig_update_config.update_vars + solver.update_config.update_params = orig_update_config.update_params + solver.update_config.update_named_expressions = ( + orig_update_config.update_named_expressions + ) + solver.update_config.update_objective = orig_update_config.update_objective + solver.update_config.treat_fixed_vars_as_params = ( + orig_update_config.treat_fixed_vars_as_params + ) + + solver.config.stream_solver = orig_config.stream_solver + solver.config.load_solution = orig_config.load_solution + + +def _single_solve( + v, + model, + solver: Union[appsi.base.Solver, appsi.base.PersistentSolver], + lb_or_ub, + obbt_info, +): + obbt_info.num_problems_attempted += 1 + # solve for lower var bound + if lb_or_ub == 'lb': + model.__obj_bounds_tightening = ScalarObjective(expr=v, sense=pyo.minimize) + else: + assert lb_or_ub == 'ub' + model.__obj_bounds_tightening = ScalarObjective(expr=v, sense=pyo.maximize) + + if solver.is_persistent(): + solver.set_objective(model.__obj_bounds_tightening) + results = solver.solve(model) + if results.termination_condition == appsi.base.TerminationCondition.optimal: + obbt_info.num_successful_problems += 1 + if results.best_objective_bound is not None and math.isfinite( + results.best_objective_bound + ): + new_bnd = results.best_objective_bound + elif results.termination_condition == appsi.base.TerminationCondition.optimal: + new_bnd = results.best_feasible_objective # assumes the problem is convex + else: + new_bnd = None + msg = f'Warning: Bounds tightening for lb for var {str(v)} was unsuccessful. Termination condition: {results.termination_condition}; The lb was not changed.' + logger.debug(msg) + + if lb_or_ub == 'lb': + orig_lb = pyo.value(v.lb) + if new_bnd is None: + new_bnd = orig_lb + elif v.has_lb(): + if new_bnd < orig_lb: + new_bnd = orig_lb + else: + orig_ub = pyo.value(v.ub) + if new_bnd is None: + new_bnd = orig_ub + elif v.has_ub(): + if new_bnd > orig_ub: + new_bnd = orig_ub + + if new_bnd is None: + # Need nan instead of None for MPI communication; This is appropriately handled in perform_obbt(). + new_bnd = np.nan + + # remove the objective function + del model.__obj_bounds_tightening + + return new_bnd + + +def _tighten_bnds( + model, + solver, + vardatalist, + lb_or_ub, + obbt_info, + with_progress_bar=False, + time_limit=math.inf, + progress_bar_string=None, +): + """ + Tighten the lower bounds of all variables in vardatalist (or self.vars_to_tighten if vardatalist is None). + + Parameters + ---------- + model: pyo.ConcreteModel or pyo.Block + solver: pyomo solver object + vardatalist: list of _GeneralVarData + lb_or_ub: str + 'lb' or 'ub' + time_limit: float + + Returns + ------- + new_bounds: list of float + """ + # solve for the new bounds + t0 = time.time() + new_bounds = list() + + obbt_info.total_num_problems += len(vardatalist) + + if with_progress_bar: + if progress_bar_string is None: + if lb_or_ub == 'lb': + bnd_str = 'LBs' + else: + bnd_str = 'UBs' + bnd_str = 'OBBT ' + bnd_str + else: + bnd_str = progress_bar_string + if mpi_available: + tqdm_position = mpiu.MPI.COMM_WORLD.Get_rank() + else: + tqdm_position = 0 + for v in tqdm( + vardatalist, ncols=100, desc=bnd_str, leave=False, position=tqdm_position + ): + if time.time() - t0 > time_limit: + if lb_or_ub == 'lb': + if v.lb is None: + new_bounds.append(np.nan) + else: + new_bounds.append(pyo.value(v.lb)) + else: + if v.ub is None: + new_bounds.append(np.nan) + else: + new_bounds.append(pyo.value(v.ub)) + else: + new_bnd = _single_solve( + v=v, + model=model, + solver=solver, + lb_or_ub=lb_or_ub, + obbt_info=obbt_info, + ) + new_bounds.append(new_bnd) + else: + for v in vardatalist: + if time.time() - t0 > time_limit: + if lb_or_ub == 'lb': + if v.lb is None: + new_bounds.append(np.nan) + else: + new_bounds.append(pyo.value(v.lb)) + else: + if v.ub is None: + new_bounds.append(np.nan) + else: + new_bounds.append(pyo.value(v.ub)) + else: + new_bnd = _single_solve( + v=v, + model=model, + solver=solver, + lb_or_ub=lb_or_ub, + obbt_info=obbt_info, + ) + new_bounds.append(new_bnd) + + return new_bounds + + +def _bt_prep(model, solver, objective_bound=None): + """ + Prepare the model for bounds tightening. + Deactivate any active objectives. + If objective_ub is not None, then add a constraint forcing the objective to be less than objective_ub + + Parameters + ---------- + model : pyo.ConcreteModel or pyo.Block + The model object that will be used for bounds tightening. + objective_bound : float + The objective value for the current best upper bound incumbent + + Returns + ------- + initial_var_values: ComponentMap + deactivated_objectives: list + orig_update_config: appsi.base.UpdateConfig + orig_config: appsi.base.SolverConfig + """ + + if solver.is_persistent(): + orig_update_config = solver.update_config() + solver.update_config.check_for_new_or_removed_constraints = False + solver.update_config.check_for_new_or_removed_vars = False + solver.update_config.check_for_new_or_removed_params = False + solver.update_config.check_for_new_objective = False + solver.update_config.update_constraints = False + solver.update_config.update_vars = False + solver.update_config.update_params = False + solver.update_config.update_named_expressions = False + solver.update_config.update_objective = False + solver.update_config.treat_fixed_vars_as_params = True + else: + orig_update_config = None + + orig_config = solver.config() + solver.config.stream_solver = False + solver.config.load_solution = False + + if solver.is_persistent(): + solver.set_instance(model) + + deactivated_objectives = list() + for obj in model.component_data_objects( + pyo.Objective, active=True, sort=True, descend_into=True + ): + deactivated_objectives.append(obj) + obj.deactivate() + + # add inequality bound on objective functions if required + # obj.expr <= objective_ub + if objective_bound is not None and math.isfinite(objective_bound): + if len(deactivated_objectives) != 1: + e = ( + 'BoundsTightener: When providing objective_ub,' + + ' the model must have one and only one objective function.' + ) + logger.error(e) + raise ValueError(e) + original_obj = deactivated_objectives[0] + if original_obj.sense == minimize: + model.__objective_ineq = pyo.Constraint( + expr=original_obj.expr <= objective_bound + ) + else: + assert original_obj.sense == maximize + model.__objective_ineq = pyo.Constraint( + expr=original_obj.expr >= objective_bound + ) + if solver.is_persistent(): + solver.add_constraints([model.__objective_ineq]) + + return deactivated_objectives, orig_update_config, orig_config + + +def _build_vardatalist(model, varlist=None, warning_threshold=0): + """ + Convert a list of pyomo variables to a list of SimpleVar and _GeneralVarData. If varlist is none, builds a + list of all variables in the model. The new list is stored in the vars_to_tighten attribute. + + Parameters + ---------- + model: ConcreteModel + varlist: None or list of pyo.Var + warning_threshold: float + The threshold below which a warning is raised when attempting to perform OBBT on variables whose + ub - lb < warning_threshold. + """ + vardatalist = None + + # if the varlist is None, then assume we want all the active variables + if varlist is None: + raise NotImplementedError('Still need to do this.') + elif isinstance(varlist, pyo.Var): + # user provided a variable, not a list of variables. Let's work with it anyway + varlist = [varlist] + + if vardatalist is None: + # expand any indexed components in the list to their + # component data objects + vardatalist = list() + for v in varlist: + if v.is_indexed(): + vardatalist.extend(v.values()) + else: + vardatalist.append(v) + + # remove from vardatalist if the variable is fixed (maybe there is a better way to do this) + corrected_vardatalist = [] + for v in vardatalist: + if not v.is_fixed(): + if v.has_lb() and v.has_ub(): + if v.ub - v.lb < warning_threshold: + e = 'Warning: Tightening a variable with ub - lb is less than {threshold}: {v}, lb: {lb}, ub: {ub}'.format( + threshold=warning_threshold, v=v, lb=v.lb, ub=v.ub + ) + logger.warning(e) + warnings.warn(e) + corrected_vardatalist.append(v) + + return corrected_vardatalist + + +def perform_obbt( + model, + solver, + varlist, + objective_bound=None, + update_bounds=True, + with_progress_bar=False, + direction='both', + time_limit=math.inf, + parallel=True, + collect_obbt_info=False, + warning_threshold=0, + progress_bar_string=None, +): + """ + Perform optimization-based bounds tighening on the variables in varlist subject to the constraints in model. + + Parameters + ---------- + model: pyo.ConcreteModel or pyo.Block + The model to be used for bounds tightening + solver: appsi.base.PersistentSolver + The solver to be used for bounds tightening. + varlist: list of pyo.Var + The variables for which OBBT should be performed. If varlist is None, then we attempt to automatically + detect which variables need tightened. + objective_bound: float + A lower or upper bound on the objective. If this is not None, then a constraint will be added to the + bounds tightening problems constraining the objective to be less than/greater than objective_bound. + update_bounds: bool + If True, then the variable bounds will be updated + with_progress_bar: bool + direction: str + Options are 'both', 'lbs', or 'ubs' + time_limit: float + The maximum amount of time to be spent performing OBBT + parallel: bool + If True, then OBBT will automatically be performed in parallel if mpirun or mpiexec was used; + If False, then OBBT will not run in parallel even if mpirun or mpiexec was used; + warning_threshold: float + The threshold below which a warning is issued when attempting to perform OBBT on variables whose + ub - lb < warning_threshold. + + Returns + ------- + lower_bounds: list of float + upper_bounds: list of float + obbt_info: OBBTInfo + + """ + if not isinstance(solver, appsi.base.Solver): + raise ValueError('Coramin requires an Appsi solver interface') + + obbt_info = OBBTInfo() + obbt_info.total_num_problems = 0 + obbt_info.num_problems_attempted = 0 + obbt_info.num_successful_problems = 0 + + t0 = time.time() + (deactivated_objectives, orig_update_config, orig_config) = _bt_prep( + model=model, solver=solver, objective_bound=objective_bound + ) + + vardata_list = _build_vardatalist( + model=model, varlist=varlist, warning_threshold=warning_threshold + ) + if mpi_available and parallel: + mpi_interface = mpiu.MPIInterface() + alloc_map = mpiu.MPIAllocationMap(mpi_interface, len(vardata_list)) + local_vardata_list = alloc_map.local_list(vardata_list) + else: + local_vardata_list = vardata_list + + exc = None + try: + if direction in {'both', 'lbs'}: + local_lower_bounds = _tighten_bnds( + model=model, + solver=solver, + vardatalist=local_vardata_list, + lb_or_ub='lb', + obbt_info=obbt_info, + with_progress_bar=with_progress_bar, + time_limit=(time_limit - (time.time() - t0)), + progress_bar_string=progress_bar_string, + ) + else: + local_lower_bounds = list() + for v in local_vardata_list: + if v.lb is None: + local_lower_bounds.append(np.nan) + else: + local_lower_bounds.append(pyo.value(v.lb)) + if direction in {'both', 'ubs'}: + local_upper_bounds = _tighten_bnds( + model=model, + solver=solver, + vardatalist=local_vardata_list, + lb_or_ub='ub', + obbt_info=obbt_info, + with_progress_bar=with_progress_bar, + time_limit=(time_limit - (time.time() - t0)), + progress_bar_string=progress_bar_string, + ) + else: + local_upper_bounds = list() + for v in local_vardata_list: + if v.ub is None: + local_upper_bounds.append(np.nan) + else: + local_upper_bounds.append(pyo.value(v.ub)) + status = 1 + msg = None + except Exception as err: + exc = err + tb = traceback.format_exc() + status = 0 + msg = str(tb) + + if mpi_available and parallel: + local_status = np.array([status], dtype='i') + global_status = np.array( + [0 for i in range(mpiu.MPI.COMM_WORLD.Get_size())], dtype='i' + ) + mpiu.MPI.COMM_WORLD.Allgatherv( + [local_status, mpiu.MPI.INT], [global_status, mpiu.MPI.INT] + ) + if not np.all(global_status): + messages = mpi_interface.comm.allgather(msg) + msg = None + for m in messages: + if m is not None: + msg = m + logger.error('An error was raised in one or more processes:\n' + msg) + raise mpiu.MPISyncError( + 'An error was raised in one or more processes:\n' + msg + ) + else: + if status != 1: + logger.error('An error was raised during OBBT:\n' + msg) + raise exc + + if mpi_available and parallel: + global_lower = alloc_map.global_list_float64(local_lower_bounds) + global_upper = alloc_map.global_list_float64(local_upper_bounds) + obbt_info.total_num_problems = mpiu.MPI.COMM_WORLD.allreduce( + obbt_info.total_num_problems + ) + obbt_info.num_problems_attempted = mpiu.MPI.COMM_WORLD.allreduce( + obbt_info.num_problems_attempted + ) + obbt_info.num_successful_problems = mpiu.MPI.COMM_WORLD.allreduce( + obbt_info.num_successful_problems + ) + else: + global_lower = local_lower_bounds + global_upper = local_upper_bounds + + tmp = list() + for i in global_lower: + if np.isnan(i): + tmp.append(None) + else: + tmp.append(float(i)) + global_lower = tmp + + tmp = list() + for i in global_upper: + if np.isnan(i): + tmp.append(None) + else: + tmp.append(float(i)) + global_upper = tmp + + _lower_bounds = None + _upper_bounds = None + if update_bounds: + _lower_bounds = global_lower + _upper_bounds = global_upper + _bt_cleanup( + model=model, + solver=solver, + vardatalist=vardata_list, + deactivated_objectives=deactivated_objectives, + orig_update_config=orig_update_config, + orig_config=orig_config, + lower_bounds=_lower_bounds, + upper_bounds=_upper_bounds, + ) + + if collect_obbt_info: + return global_lower, global_upper, obbt_info + else: + return global_lower, global_upper diff --git a/pyomo/contrib/coramin/domain_reduction/tests/__init__.py b/pyomo/contrib/coramin/domain_reduction/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/domain_reduction/tests/test_dbt.py b/pyomo/contrib/coramin/domain_reduction/tests/test_dbt.py new file mode 100644 index 00000000000..8cb3c24a8e7 --- /dev/null +++ b/pyomo/contrib/coramin/domain_reduction/tests/test_dbt.py @@ -0,0 +1,878 @@ +from pyomo.contrib.coramin.domain_reduction.dbt import ( + TreeBlock, + TreeBlockError, + convert_pyomo_model_to_bipartite_graph, + _VarNode, + _ConNode, + _RelNode, + split_metis, + num_cons_in_graph, + collect_vars_to_tighten_by_block, + decompose_model, + perform_dbt, + OBBTMethod, + FilterMethod, + DecompositionStatus, + compute_partition_ratio, +) +from pyomo.common import unittest +import pyomo.environ as pe +from pyomo.contrib import coramin +from networkx import is_bipartite +from pyomo.common.collections import ComponentSet +from networkx import Graph +import filecmp +from pyomo.contrib import appsi +import pytest +from pyomo.contrib.coramin.utils.compare_models import is_relaxation, is_equivalent +from pyomo.contrib.coramin.utils.pyomo_utils import active_cons, active_vars + +try: + import metis + + metis_available = True +except: + metis_available = False + + +if not metis_available: + raise unittest.SkipTest('metis is not available') + + +class TestDecomposition(unittest.TestCase): + def test_decomp1(self): + m1 = pe.ConcreteModel() + m1.x = x = pe.Var([1, 2, 3, 4, 5, 6], bounds=(-10, 10)) + m1.c = c = pe.ConstraintList() + + c.add(x[1] == x[2] + x[3]) + c.add(x[4] == x[5] + x[6]) + c.add(x[2] <= 2 * x[3] + 1) + c.add(x[5] >= 2 * x[6] + 1) + + m2, reason = decompose_model(m1) + self.assertEqual(reason, DecompositionStatus.normal) + self.assertTrue(is_equivalent(m1, m2, appsi.solvers.Highs())) + self.assertEqual(len(m2.children), 2) + self.assertEqual(len(list(active_cons(m2.children[0]))), 2) + self.assertEqual(len(list(active_cons(m2.children[1]))), 2) + self.assertEqual(len(list(active_vars(m2.children[0]))), 3) + self.assertEqual(len(list(active_vars(m2.children[1]))), 3) + self.assertEqual(m2.get_block_stage(m2), 0) + self.assertEqual(m2.get_block_stage(m2.children[0]), 1) + self.assertEqual(m2.get_block_stage(m2.children[1]), 1) + self.assertEqual(list(m2.stage_blocks(0)), [m2]) + self.assertEqual(list(m2.stage_blocks(1)), [m2.children[0], m2.children[1]]) + + def test_decomp2(self): + m1 = pe.ConcreteModel() + m1.x = x = pe.Var([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], bounds=(-10, 10)) + m1.c = c = pe.ConstraintList() + + c.add(x[1] == x[2] + x[3]) + c.add(x[4] == x[5] + x[6]) + c.add(x[2] <= 2 * x[3] + 1) + c.add(x[5] >= 2 * x[6] + 1) + c.add(x[1] == x[4]) + + c.add(x[7] == x[8] + x[9]) + c.add(x[10] == x[11] + x[12]) + c.add(x[8] <= 2 * x[9] + 1) + c.add(x[11] >= 2 * x[12] + 1) + c.add(x[7] == x[10]) + + m2, reason = decompose_model(m1, limit_num_stages=False) + self.assertEqual(reason, DecompositionStatus.normal) + self.assertTrue(is_equivalent(m1, m2, appsi.solvers.Highs())) + self.assertEqual(len(m2.children), 2) + self.assertEqual(len(list(active_cons(m2.children[0]))), 5) + self.assertEqual(len(list(active_cons(m2.children[1]))), 5) + self.assertEqual(len(list(active_vars(m2.children[0]))), 6) + self.assertEqual(len(list(active_vars(m2.children[1]))), 6) + self.assertEqual(m2.get_block_stage(m2), 0) + self.assertEqual(m2.get_block_stage(m2.children[0]), 1) + self.assertEqual(m2.get_block_stage(m2.children[1]), 1) + self.assertEqual(list(m2.stage_blocks(0)), [m2]) + self.assertEqual(list(m2.stage_blocks(1)), [m2.children[0], m2.children[1]]) + self.assertEqual( + list(m2.stage_blocks(2)), + [ + m2.children[0].children[0], + m2.children[0].children[1], + m2.children[1].children[0], + m2.children[1].children[1], + ], + ) + + for b in [m2.children[0], m2.children[1]]: + self.assertEqual(len(b.children), 2) + self.assertIn(len(list(active_cons(b.children[0]))), {2, 3}) + self.assertIn(len(list(active_cons(b.children[1]))), {2, 3}) + self.assertIn(len(list(active_vars(b.children[0]))), {3, 4}) + self.assertIn(len(list(active_vars(b.children[1]))), {3, 4}) + self.assertEqual(m2.get_block_stage(b), 1) + self.assertEqual(m2.get_block_stage(b.children[0]), 2) + self.assertEqual(m2.get_block_stage(b.children[1]), 2) + self.assertEqual(len(b.coupling_vars), 1) + + def test_decomp3(self): + m1 = pe.ConcreteModel() + m1.x = x = pe.Var([1, 2, 3, 4, 5, 6], bounds=(-10, 10)) + m1.c = c = pe.ConstraintList() + m1.rels = pe.Block() + + c.add(x[1] == x[2] + x[3]) + c.add(x[4] == x[5] + x[6]) + m1.rels.rel1 = coramin.relaxations.PWMcCormickRelaxation() + m1.rels.rel1.build(x[2], x[3], aux_var=x[1]) + m1.rels.rel2 = coramin.relaxations.PWMcCormickRelaxation() + m1.rels.rel2.build(x[5], x[6], aux_var=x[4]) + + m2, reason = decompose_model(m1) + self.assertEqual(reason, DecompositionStatus.normal) + self.assertTrue(is_equivalent(m1, m2, appsi.solvers.Highs())) + self.assertEqual(len(m2.children), 2) + self.assertEqual( + len( + list( + coramin.relaxations.iterators.nonrelaxation_component_data_objects( + m2.children[0], pe.Constraint, descend_into=True, active=True + ) + ) + ), + 1, + ) + self.assertEqual( + len( + list( + coramin.relaxations.iterators.nonrelaxation_component_data_objects( + m2.children[1], pe.Constraint, descend_into=True, active=True + ) + ) + ), + 1, + ) + self.assertEqual( + len( + list( + coramin.relaxations.iterators.relaxation_data_objects( + m2.children[0], descend_into=True, active=True + ) + ) + ), + 1, + ) + self.assertEqual( + len( + list( + coramin.relaxations.iterators.relaxation_data_objects( + m2.children[1], descend_into=True, active=True + ) + ) + ), + 1, + ) + self.assertEqual(len(list(active_vars(m2.children[0]))), 3) + self.assertEqual(len(list(active_vars(m2.children[1]))), 3) + self.assertEqual(m2.get_block_stage(m2), 0) + self.assertEqual(m2.get_block_stage(m2.children[0]), 1) + self.assertEqual(m2.get_block_stage(m2.children[1]), 1) + self.assertEqual(list(m2.stage_blocks(0)), [m2]) + self.assertEqual(list(m2.stage_blocks(1)), [m2.children[0], m2.children[1]]) + pr = compute_partition_ratio(m1, m2) + self.assertAlmostEqual(pr, 2) + + def test_objective(self): + m1 = pe.ConcreteModel() + m1.x = x = pe.Var([1, 2, 3, 4, 5, 6], bounds=(-10, 10)) + m1.c = c = pe.ConstraintList() + + c.add(x[1] == x[2] + x[3]) + c.add(x[4] == x[5] + x[6]) + c.add(x[2] <= 2 * x[3] + 1) + c.add(x[5] >= 2 * x[6] + 1) + m1.obj = pe.Objective(expr=sum(x.values())) + + m2, reason = decompose_model(m1) + self.assertEqual(reason, DecompositionStatus.normal) + opt = appsi.solvers.Highs() + res1 = opt.solve(m1) + res2 = opt.solve(m2) + self.assertAlmostEqual( + res1.best_feasible_objective, res2.best_feasible_objective + ) + self.assertEqual(len(m2.children), 2) + self.assertIn(len(list(active_cons(m2.children[0]))), {3, 4}) + self.assertIn(len(list(active_cons(m2.children[1]))), {3, 4}) + self.assertIn(len(list(active_vars(m2.children[0]))), {4, 5}) + self.assertIn(len(list(active_vars(m2.children[1]))), {4, 5}) + self.assertEqual(m2.get_block_stage(m2), 0) + self.assertEqual(m2.get_block_stage(m2.children[0]), 1) + self.assertEqual(m2.get_block_stage(m2.children[1]), 1) + self.assertEqual(list(m2.stage_blocks(0)), [m2]) + self.assertEqual(list(m2.stage_blocks(1)), [m2.children[0], m2.children[1]]) + + def test_refine_partition(self): + m1 = pe.ConcreteModel() + m1.x = x = pe.Var([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], bounds=(-10, 10)) + m1.c = c = pe.ConstraintList() + + c.add(x[1] == x[2] + x[3]) + c.add(x[4] == x[5] + x[6]) + c.add(x[2] <= 2 * x[3] + 1) + c.add(x[5] >= 2 * x[6] + 1) + c.add(x[1] == x[4]) + + c.add(x[7] == x[8] + x[9]) + c.add(x[10] == x[11] + x[12]) + c.add(x[8] <= 2 * x[9] + 1) + c.add(x[11] >= 2 * x[12] + 1) + c.add(x[7] == x[10]) + + c.add(sum(x.values()) == 1) + m2, reason = decompose_model(m1) + self.assertEqual(reason, DecompositionStatus.normal) + opt = appsi.solvers.Highs() + opt.config.stream_solver = True + self.assertTrue(is_relaxation(m1, m2, appsi.solvers.Highs(), bigM=1000)) + self.assertTrue(is_relaxation(m2, m1, opt, bigM=1000)) + self.assertTrue(is_equivalent(m1, m2, appsi.solvers.Highs(), bigM=1000)) + self.assertEqual(len(m2.children), 2) + self.assertEqual(len(m2.coupling_vars), 1) + self.assertIn(len(list(active_cons(m2.children[0]))), {6, 7}) + self.assertIn(len(list(active_cons(m2.children[1]))), {6, 7}) + self.assertEqual(len(m2.coupling_vars), 1) + self.assertIn(len(list(active_vars(m2.children[0]))), {7, 8}) + self.assertIn(len(list(active_vars(m2.children[1]))), {7, 8}) + + +class TestTreeBlock(unittest.TestCase): + def test_tree_block(self): + b = TreeBlock(concrete=True) + with self.assertRaises(TreeBlockError): + b.is_leaf() + with self.assertRaises(TreeBlockError): + b.children + with self.assertRaises(TreeBlockError): + b.num_stages() + with self.assertRaises(TreeBlockError): + list(b.stage_blocks(0)) + with self.assertRaises(TreeBlockError): + b.get_block_stage(b) + b.setup(children_keys=list(), coupling_vars=list()) + self.assertTrue(b.is_leaf()) + b.x = pe.Var() # make sure we can add components just like a regular block + b.x.setlb(-1) + with self.assertRaises(TreeBlockError): + b.children + self.assertEqual(b.num_stages(), 1) + stage0_blocks = list(b.stage_blocks(0)) + self.assertEqual(len(stage0_blocks), 1) + self.assertIs(stage0_blocks[0], b) + stage1_blocks = list(b.stage_blocks(1)) + self.assertEqual(len(stage1_blocks), 0) + self.assertEqual(b.get_block_stage(b), 0) + + b = TreeBlock(concrete=True) + b.setup(children_keys=[1, 2], coupling_vars=list()) + b.children[1].setup(children_keys=list(), coupling_vars=list()) + b.children[2].setup(children_keys=['a', 'b'], coupling_vars=list()) + b.children[2].children['a'].setup(children_keys=list(), coupling_vars=list()) + b.children[2].children['b'].setup(children_keys=list(), coupling_vars=list()) + self.assertFalse(b.is_leaf()) + self.assertTrue(b.children[1].is_leaf()) + self.assertFalse(b.children[2].is_leaf()) + self.assertTrue(b.children[2].children['a'].is_leaf()) + self.assertTrue(b.children[2].children['b'].is_leaf()) + + b.children[1].x = pe.Var() + b.children[2].children['a'].x = pe.Var() + b.children[2].children['b'].x = pe.Var() + self.assertEqual( + len(list(b.component_data_objects(pe.Var, descend_into=True, sort=True))), 3 + ) + + self.assertEqual(b.num_stages(), 3) + with self.assertRaises(TreeBlockError): + b.children[1].num_stages() + with self.assertRaises(TreeBlockError): + b.children[2].num_stages() + with self.assertRaises(TreeBlockError): + b.children[2].children['a'].num_stages() + with self.assertRaises(TreeBlockError): + b.children[2].children['b'].num_stages() + + stage0_blocks = list(b.stage_blocks(0)) + stage1_blocks = list(b.stage_blocks(1)) + stage2_blocks = list(b.stage_blocks(2)) + stage3_blocks = list(b.stage_blocks(3)) + self.assertEqual(len(stage0_blocks), 1) + self.assertEqual(len(stage1_blocks), 2) + self.assertEqual(len(stage2_blocks), 2) + self.assertEqual(len(stage3_blocks), 0) + self.assertIs(stage0_blocks[0], b) + self.assertIs(stage1_blocks[0], b.children[1]) + self.assertIs(stage1_blocks[1], b.children[2]) + self.assertIs(stage2_blocks[0], b.children[2].children['a']) + self.assertIs(stage2_blocks[1], b.children[2].children['b']) + with self.assertRaises(TreeBlockError): + list(b.children[2].stage_blocks(0)) + b.children[1].deactivate() + stage1_blocks = list(b.stage_blocks(1, active=True)) + self.assertEqual(len(stage1_blocks), 1) + self.assertIs(stage1_blocks[0], b.children[2]) + stage1_blocks = list(b.stage_blocks(1)) + self.assertEqual(len(stage1_blocks), 2) + self.assertIs(stage1_blocks[0], b.children[1]) + self.assertIs(stage1_blocks[1], b.children[2]) + + self.assertEqual(b.get_block_stage(b), 0) + self.assertEqual(b.get_block_stage(b.children[1]), 1) + self.assertEqual(b.get_block_stage(b.children[2]), 1) + self.assertEqual(b.get_block_stage(b.children[2].children['a']), 2) + self.assertEqual(b.get_block_stage(b.children[2].children['b']), 2) + b.children[1].foo = pe.Block() + self.assertIs(b.get_block_stage(b.children[1].foo), None) + with self.assertRaises(TreeBlockError): + b.children[2].get_block_stage(b.children[2].children['a']) + + self.assertEqual(len(b.coupling_vars), 0) + + +class TestGraphConversion(unittest.TestCase): + def test_convert_pyomo_model_to_bipartite_graph(self): + m = pe.ConcreteModel() + m.x = pe.Var() + m.y = pe.Var() + m.z = pe.Var() + m.c1 = pe.Constraint(expr=m.z >= m.x + m.y) + m.c2 = coramin.relaxations.PWXSquaredRelaxation() + m.c2.build(x=m.x, aux_var=m.z) + m.c3 = pe.Constraint(expr=m.z >= m.x - m.y) + + graph = convert_pyomo_model_to_bipartite_graph(m) + self.assertTrue(is_bipartite(graph)) + self.assertEqual(graph.number_of_nodes(), 6) + self.assertEqual(graph.number_of_edges(), 8) + graph_node_comps = ComponentSet([i.comp for i in graph.nodes()]) + self.assertEqual(len(graph_node_comps), 6) + self.assertIn(m.x, graph_node_comps) + self.assertIn(m.y, graph_node_comps) + self.assertIn(m.z, graph_node_comps) + self.assertIn(m.c1, graph_node_comps) + self.assertIn(m.c2, graph_node_comps) + self.assertIn(m.c3, graph_node_comps) + graph_edge_comps = {(id(i.comp), id(j.comp)) for i, j in graph.edges()} + self.assertTrue( + ((id(m.x), id(m.c1)) in graph_edge_comps) + or ((id(m.c1), id(m.x)) in graph_edge_comps) + ) + self.assertTrue( + ((id(m.y), id(m.c1)) in graph_edge_comps) + or ((id(m.c1), id(m.y)) in graph_edge_comps) + ) + self.assertTrue( + ((id(m.z), id(m.c1)) in graph_edge_comps) + or ((id(m.c1), id(m.z)) in graph_edge_comps) + ) + self.assertTrue( + ((id(m.x), id(m.c2)) in graph_edge_comps) + or ((id(m.c2), id(m.x)) in graph_edge_comps) + ) + self.assertFalse( + ((id(m.y), id(m.c2)) in graph_edge_comps) + or ((id(m.c2), id(m.y)) in graph_edge_comps) + ) + self.assertTrue( + ((id(m.z), id(m.c2)) in graph_edge_comps) + or ((id(m.c2), id(m.z)) in graph_edge_comps) + ) + self.assertTrue( + ((id(m.x), id(m.c3)) in graph_edge_comps) + or ((id(m.c3), id(m.x)) in graph_edge_comps) + ) + self.assertTrue( + ((id(m.y), id(m.c3)) in graph_edge_comps) + or ((id(m.c3), id(m.y)) in graph_edge_comps) + ) + self.assertTrue( + ((id(m.z), id(m.c3)) in graph_edge_comps) + or ((id(m.c3), id(m.z)) in graph_edge_comps) + ) + self.assertEqual(num_cons_in_graph(graph=graph, include_rels=True), 3) + self.assertEqual(num_cons_in_graph(graph=graph, include_rels=False), 2) + + +class TestSplit(unittest.TestCase): + def setUp(self): + m = pe.ConcreteModel() + self.m = m + m.v1 = pe.Var() + m.v2 = pe.Var(bounds=(-1, 1)) + m.v3 = pe.Var(bounds=(-1, 1)) + m.v6 = pe.Var() + m.v4 = pe.Var(bounds=(-1, 1)) + m.v5 = pe.Var(bounds=(-1, 1)) + + m.c1 = pe.Constraint(expr=m.v1 - m.v2 - m.v3 == 0) + m.c2 = pe.Constraint(expr=m.v6 - m.v4 - m.v5 == 0) + m.r1 = coramin.relaxations.PWMcCormickRelaxation() + m.r1.set_input(x1=m.v4, x2=m.v5, aux_var=m.v6) + m.r2 = coramin.relaxations.PWMcCormickRelaxation() + m.r2.set_input(x1=m.v3, x2=m.v4, aux_var=m.v2) + + def test_split_metis(self): + m = self.m + + g = Graph() + v1 = _VarNode(m.v1) + v2 = _VarNode(m.v2) + v3 = _VarNode(m.v3) + v4 = _VarNode(m.v4) + v5 = _VarNode(m.v5) + v6 = _VarNode(m.v6) + c1 = _ConNode(m.c1) + c2 = _ConNode(m.c2) + r1 = _RelNode(m.r1) + r2 = _RelNode(m.r2) + + g.add_edge(v2, r2) + g.add_edge(v3, r2) + g.add_edge(v4, r2) + g.add_edge(v1, c1) + g.add_edge(v2, c1) + g.add_edge(v3, c1) + g.add_edge(v4, r1) + g.add_edge(v5, r1) + g.add_edge(v6, r1) + g.add_edge(v4, c2) + g.add_edge(v5, c2) + g.add_edge(v6, c2) + + tree, partitioning_ratio = split_metis(graph=g, model=m) + self.assertAlmostEqual(partitioning_ratio, 3 * 12 / (12 * 1 + 6 * 2 + 6 * 2)) + + children = list(tree.children) + self.assertEqual(len(children), 2) + graph_a = children[0] + graph_b = children[1] + if v1 in graph_b.nodes(): + graph_a, graph_b = graph_b, graph_a + + graph_a_nodes = set(graph_a.nodes()) + graph_b_nodes = set(graph_b.nodes()) + self.assertIn(v1, graph_a_nodes) + self.assertIn(v2, graph_a_nodes) + self.assertIn(v3, graph_a_nodes) + self.assertIn(v4, graph_b_nodes) + self.assertIn(v5, graph_b_nodes) + self.assertIn(v6, graph_b_nodes) + self.assertIn(r2, graph_a_nodes) + self.assertIn(c1, graph_a_nodes) + self.assertIn(r1, graph_b_nodes) + self.assertIn(c2, graph_b_nodes) + self.assertEqual(len(graph_a_nodes), 6) + self.assertEqual(len(graph_b_nodes), 5) + v4_hat = list(graph_a_nodes - {v1, v2, v3, c1, r2})[0] + + graph_a_edges = set(graph_a.edges()) + graph_b_edges = set(graph_b.edges()) + self.assertTrue((v2, r2) in graph_a_edges or (r2, v2) in graph_a_edges) + self.assertTrue((v3, r2) in graph_a_edges or (r2, v3) in graph_a_edges) + self.assertTrue((v4_hat, r2) in graph_a_edges or (r2, v4_hat) in graph_a_edges) + self.assertTrue((v1, c1) in graph_a_edges or (c1, v1) in graph_a_edges) + self.assertTrue((v2, c1) in graph_a_edges or (c1, v2) in graph_a_edges) + self.assertTrue((v3, c1) in graph_a_edges or (c1, v3) in graph_a_edges) + self.assertTrue((v4, r1) in graph_b_edges or (r1, v4) in graph_b_edges) + self.assertTrue((v5, r1) in graph_b_edges or (r1, v5) in graph_b_edges) + self.assertTrue((v6, r1) in graph_b_edges or (r1, v6) in graph_b_edges) + self.assertTrue((v4, c2) in graph_b_edges or (c2, v4) in graph_b_edges) + self.assertTrue((v5, c2) in graph_b_edges or (c2, v5) in graph_b_edges) + self.assertTrue((v6, c2) in graph_b_edges or (c2, v6) in graph_b_edges) + self.assertEqual(len(graph_a_edges), 6) + self.assertEqual(len(graph_b_edges), 6) + + coupling_vars = list(tree.coupling_vars) + self.assertEqual(len(coupling_vars), 1) + cv = coupling_vars[0] + self.assertEqual(v4, cv) + + new_model = TreeBlock(concrete=True) + tree.build_pyomo_model(block=new_model) + new_vars = list(active_vars(new_model)) + new_cons = list( + coramin.relaxations.nonrelaxation_component_data_objects( + new_model, + ctype=pe.Constraint, + active=True, + descend_into=True, + sort=True, + ) + ) + new_rels = list( + coramin.relaxations.relaxation_data_objects( + new_model, descend_into=True, active=True, sort=True + ) + ) + self.assertEqual(len(new_vars), 6) + self.assertEqual(len(new_cons), 2) + self.assertEqual(len(new_rels), 2) + self.assertEqual(len(new_model.children), 2) + self.assertEqual(len(new_model.coupling_vars), 1) + self.assertEqual(new_model.num_stages(), 2) + + stage0_vars = list( + new_model.component_data_objects(pe.Var, descend_into=False, sort=True) + ) + stage0_cons = list( + new_model.component_data_objects( + pe.Constraint, descend_into=False, sort=True, active=True + ) + ) + stage0_rels = list( + coramin.relaxations.relaxation_data_objects( + new_model, descend_into=False, active=True, sort=True + ) + ) + self.assertEqual(len(stage0_vars), 0) + self.assertEqual(len(stage0_cons), 0) + self.assertEqual(len(stage0_rels), 0) + + block_a = new_model.children[0] + block_b = new_model.children[1] + block_a_vars = ComponentSet(active_vars(block_a)) + block_b_vars = ComponentSet(active_vars(block_b)) + block_a_cons = ComponentSet( + coramin.relaxations.nonrelaxation_component_data_objects( + block_a, ctype=pe.Constraint, descend_into=True, active=True, sort=True + ) + ) + block_b_cons = ComponentSet( + coramin.relaxations.nonrelaxation_component_data_objects( + block_b, ctype=pe.Constraint, descend_into=True, active=True, sort=True + ) + ) + block_a_rels = ComponentSet( + coramin.relaxations.relaxation_data_objects( + block_a, descend_into=True, active=True, sort=True + ) + ) + block_b_rels = ComponentSet( + coramin.relaxations.relaxation_data_objects( + block_b, descend_into=True, active=True, sort=True + ) + ) + + self.assertIn(len(block_a_vars), {3, 4}) + self.assertEqual(len(block_a_cons), 1) + self.assertEqual(len(block_a_rels), 1) + self.assertIn(len(block_b_vars), {3, 4}) + self.assertEqual(len(block_b_cons), 1) + self.assertEqual(len(block_b_rels), 1) + + self.assertEqual(new_model.coupling_vars, [m.v4]) + + +class TestNumCons(unittest.TestCase): + def test_num_cons(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.z = pe.Var() + m.r = coramin.relaxations.PWUnivariateRelaxation() + m.r.build( + x=m.x, + aux_var=m.y, + shape=coramin.utils.FunctionShape.CONVEX, + f_x_expr=pe.exp(m.x), + ) + m.c = pe.Constraint(expr=m.z == 2 * m.x) + g = convert_pyomo_model_to_bipartite_graph(m) + self.assertEqual(num_cons_in_graph(g, include_rels=False), 1) + self.assertEqual(num_cons_in_graph(g), 2) + + +class TestVarsToTightenByBlock(unittest.TestCase): + def test_vars_to_tighten_by_block(self): + m = TreeBlock(concrete=True) + m.setup(children_keys=[1, 2]) + b1 = m.children[1] + b2 = m.children[2] + b1.setup(children_keys=list()) + b2.setup(children_keys=list()) + + b1.x = pe.Var(bounds=(-1, 1)) + b1.y = pe.Var() + b1.z = pe.Var() + b1.aux = pe.Var() + + b2.x = pe.Var(bounds=(-1, 1)) + b2.y = pe.Var() + b2.aux = pe.Var() + + b1.c = pe.Constraint(expr=b1.x + b1.y + b1.z == 0) + b2.c = pe.Constraint(expr=b2.x + b2.y + b1.z == 0) + + b1.r = coramin.relaxations.PWUnivariateRelaxation() + b1.r.set_input( + x=b1.x, + aux_var=b1.aux, + shape=coramin.utils.FunctionShape.CONVEX, + f_x_expr=pe.exp(b1.x), + ) + b1.r.rebuild() + + b2.r = coramin.relaxations.PWXSquaredRelaxation() + b2.r.set_input( + x=b2.x, aux_var=b2.aux, relaxation_side=coramin.utils.RelaxationSide.UNDER + ) + b2.r.rebuild() + + m.coupling_vars.append(b1.z) + + vars_to_tighten_by_block = collect_vars_to_tighten_by_block( + m, method='full_space' + ) + self.assertEqual(len(vars_to_tighten_by_block), 1) + vars_to_tighten = vars_to_tighten_by_block[m] + self.assertEqual(len(vars_to_tighten), 1) + self.assertIn(b1.x, vars_to_tighten) + + vars_to_tighten_by_block = collect_vars_to_tighten_by_block(m, method='leaves') + self.assertIn(len(vars_to_tighten_by_block), {2, 3}) + vars_to_tighten = vars_to_tighten_by_block[m] + self.assertEqual(len(vars_to_tighten), 0) + vars_to_tighten = vars_to_tighten_by_block[b1] + self.assertEqual(len(vars_to_tighten), 1) + self.assertIn(b1.x, vars_to_tighten) + vars_to_tighten = vars_to_tighten_by_block[b2] + self.assertEqual(len(vars_to_tighten), 0) + + vars_to_tighten_by_block = collect_vars_to_tighten_by_block(m, method='dbt') + self.assertEqual(len(vars_to_tighten_by_block), 3) + vars_to_tighten = vars_to_tighten_by_block[m] + self.assertEqual(len(vars_to_tighten), 1) + self.assertTrue(b1.z in vars_to_tighten) + vars_to_tighten = vars_to_tighten_by_block[b1] + self.assertEqual(len(vars_to_tighten), 1) + self.assertIn(b1.x, vars_to_tighten) + vars_to_tighten = vars_to_tighten_by_block[b2] + self.assertEqual(len(vars_to_tighten), 0) + + +class TestDBT(unittest.TestCase): + def get_model(self): + m = TreeBlock(concrete=True) + m.setup(children_keys=[0, 1]) + b0 = m.children[0] + b1 = m.children[1] + b0.setup(children_keys=list()) + b1.setup(children_keys=list()) + + b0.x = pe.Var(bounds=(-1, 1)) + b0.y = pe.Var(bounds=(-5, 5)) + b0.p = pe.Param(initialize=1.0, mutable=True) + b0.c = coramin.relaxations.PWUnivariateRelaxation() + b0.c.build( + x=b0.x, + aux_var=b0.y, + shape=coramin.utils.FunctionShape.CONVEX, + f_x_expr=b0.p * b0.x, + ) + + b1.x = pe.Var(bounds=(-5, 5)) + b1.p = pe.Param(initialize=1.0, mutable=True) + b1.c = coramin.relaxations.PWUnivariateRelaxation() + b1.c.build( + x=b1.x, + aux_var=b0.y, + shape=coramin.utils.FunctionShape.CONVEX, + f_x_expr=b1.p * b1.x, + ) + + m.coupling_vars.append(b0.y) + + return m + + def test_full_space(self): + m = self.get_model() + b0 = m.children[0] + b1 = m.children[1] + opt = appsi.solvers.Highs() + perform_dbt( + relaxation=m, + solver=opt, + obbt_method=OBBTMethod.FULL_SPACE, + filter_method=FilterMethod.NONE, + ) + self.assertAlmostEqual(b0.x.lb, -1) + self.assertAlmostEqual(b0.x.ub, 1) + self.assertAlmostEqual(b0.y.lb, -5) + self.assertAlmostEqual(b0.y.ub, 5) + self.assertAlmostEqual(b1.x.lb, -1) + self.assertAlmostEqual(b1.x.ub, 1) + + def test_leaves(self): + m = self.get_model() + b0 = m.children[0] + b1 = m.children[1] + opt = appsi.solvers.Gurobi() + perform_dbt( + relaxation=m, + solver=opt, + obbt_method=OBBTMethod.LEAVES, + filter_method=FilterMethod.NONE, + ) + self.assertAlmostEqual(b0.x.lb, -1) + self.assertAlmostEqual(b0.x.ub, 1) + self.assertAlmostEqual(b0.y.lb, -5) + self.assertAlmostEqual(b0.y.ub, 5) + self.assertAlmostEqual(b1.x.lb, -5) + self.assertAlmostEqual(b1.x.ub, 5) + + def test_dbt(self): + m = self.get_model() + b0 = m.children[0] + b1 = m.children[1] + opt = appsi.solvers.Highs() + perform_dbt( + relaxation=m, + solver=opt, + obbt_method=OBBTMethod.DECOMPOSED, + filter_method=FilterMethod.NONE, + ) + self.assertAlmostEqual(b0.x.lb, -1) + self.assertAlmostEqual(b0.x.ub, 1) + self.assertAlmostEqual(b0.y.lb, -1) + self.assertAlmostEqual(b0.y.ub, 1) + self.assertAlmostEqual(b1.x.lb, -1) + self.assertAlmostEqual(b1.x.ub, 1) + + def test_dbt_with_filter(self): + m = self.get_model() + b0 = m.children[0] + b1 = m.children[1] + opt = appsi.solvers.Highs() + perform_dbt( + relaxation=m, + solver=opt, + obbt_method=OBBTMethod.DECOMPOSED, + filter_method=FilterMethod.AGGRESSIVE, + ) + self.assertAlmostEqual(b0.x.lb, -1) + self.assertAlmostEqual(b0.x.ub, 1) + self.assertAlmostEqual(b0.y.lb, -1) + self.assertAlmostEqual(b0.y.ub, 1) + self.assertAlmostEqual(b1.x.lb, -1) + self.assertAlmostEqual(b1.x.ub, 1) + + +class TestDBTWithECP(unittest.TestCase): + def create_model(self): + m = coramin.domain_reduction.TreeBlock(concrete=True) + m.setup(children_keys=[1, 2]) + m.children[1].setup(children_keys=[1, 2]) + m.children[2].setup(children_keys=[1, 2]) + m.children[1].children[1].setup(children_keys=list()) + m.children[1].children[2].setup(children_keys=list()) + m.children[2].children[1].setup(children_keys=list()) + m.children[2].children[2].setup(children_keys=list()) + + b1 = m.children[1].children[1] + b2 = m.children[1].children[2] + b3 = m.children[2].children[1] + b4 = m.children[2].children[2] + + b1.x1 = pe.Var(bounds=(0.5, 5)) + b1.x2 = pe.Var(bounds=(0.5, 5)) + b1.x3 = pe.Var(bounds=(0.5, 5)) + + b2.x4 = pe.Var(bounds=(0.5, 5)) + b2.x5 = pe.Var(bounds=(0.5, 5)) + b2.x6 = pe.Var(bounds=(0.5, 5)) + + b3.x7 = pe.Var(bounds=(0.5, 5)) + b3.x8 = pe.Var(bounds=(0.5, 5)) + b3.x9 = pe.Var(bounds=(0.5, 5)) + + b4.x10 = pe.Var(bounds=(0.5, 5)) + b4.x11 = pe.Var(bounds=(0.5, 5)) + b4.x12 = pe.Var(bounds=(0.5, 5)) + + b1.c1 = pe.Constraint(expr=b1.x1 == b1.x2**2 - b1.x3**2) + b1.c2 = pe.Constraint(expr=b1.x2 == pe.log(b1.x3) + b1.x3) + + b2.c1 = pe.Constraint(expr=b2.x4 == b2.x5 * b2.x6) + b2.c2 = pe.Constraint(expr=b2.x5 == b2.x6**2) + + b3.c1 = pe.Constraint(expr=b3.x7 == pe.log(b3.x8) - pe.log(b3.x9)) + b3.c2 = pe.Constraint(expr=b3.x8 + b3.x9 == 4) + + b4.c1 = pe.Constraint(expr=b4.x10 == b4.x11 * b4.x12 - b4.x12) + b4.c2 = pe.Constraint(expr=b4.x11 + b4.x12 == 4) + + m.children[1].linking_constraints.add(b1.x3 == b2.x6) + m.children[2].linking_constraints.add(b3.x9 == b4.x10) + m.linking_constraints.add(b1.x3 == b3.x9) + + m.obj = pe.Objective( + expr=b1.x1 + + b1.x2 + + b1.x3 + + b2.x4 + + b2.x5 + + b2.x6 + + b3.x7 + + b3.x8 + + b3.x9 + + b4.x10 + + b4.x11 + + b4.x12 + ) + + return m + + @pytest.mark.mpi + def test_bounds_tightening(self): + from mpi4py import MPI + + comm: MPI.Comm = MPI.COMM_WORLD + rank = comm.Get_rank() + + m = self.create_model() + coramin.relaxations.relax(m, descend_into=True, in_place=True) + opt = coramin.algorithms.ECPBounder(subproblem_solver=appsi.solvers.Gurobi()) + opt.config.keep_cuts = False + opt.config.feasibility_tol = 1e-5 + coramin.domain_reduction.perform_dbt( + m, + opt, + filter_method=coramin.domain_reduction.FilterMethod.NONE, + parallel=True, + ) + m.write(f'rank{rank}.lp') + comm.Barrier() + if rank == 0: + self.assertTrue(filecmp.cmp('rank1.lp', f'rank{rank}.lp'), f'rank {rank}') + else: + self.assertTrue(filecmp.cmp('rank0.lp', f'rank{rank}.lp'), f'rank {rank}') + + # the next bit of code is needed to ensure the above test actually tests what we think it is testing + m = self.create_model() + coramin.relaxations.relax(m, descend_into=True, in_place=True) + opt = coramin.algorithms.ECPBounder(subproblem_solver=appsi.solvers.Gurobi()) + opt.config.keep_cuts = False + opt.config.feasibility_tol = 1e-5 + coramin.domain_reduction.perform_dbt( + m, + opt, + filter_method=coramin.domain_reduction.FilterMethod.NONE, + parallel=True, + update_relaxations_between_stages=False, + ) + m.write(f'rank{rank}.lp') + comm.Barrier() + if rank == 0: + self.assertFalse(filecmp.cmp('rank1.lp', f'rank{rank}.lp')) + else: + self.assertFalse(filecmp.cmp('rank0.lp', f'rank{rank}.lp')) diff --git a/pyomo/contrib/coramin/domain_reduction/tests/test_filters.py b/pyomo/contrib/coramin/domain_reduction/tests/test_filters.py new file mode 100644 index 00000000000..8e614e3a3da --- /dev/null +++ b/pyomo/contrib/coramin/domain_reduction/tests/test_filters.py @@ -0,0 +1,39 @@ +import pyomo.environ as pe +from pyomo.contrib import coramin +from pyomo.common import unittest +from pyomo.contrib import appsi + + +highs_available = appsi.solvers.Highs().available() + + +@unittest.skipUnless(highs_available, 'HiGHS is not available') +class TestFilters(unittest.TestCase): + def test_basic_filter(self): + m = pe.ConcreteModel() + m.y = pe.Var() + m.x = pe.Var(bounds=(-2, -1)) + m.obj = pe.Objective(expr=m.y) + m.c = pe.Constraint(expr=m.y == -m.x**2) + r = coramin.relaxations.relax(m) + opt = appsi.solvers.Highs() + res = opt.solve(r) + (vars_to_min, vars_to_max) = ( + coramin.domain_reduction.filter_variables_from_solution([m.x]) + ) + self.assertIn(m.x, vars_to_max) + self.assertNotIn(m.x, vars_to_min) + + def test_aggressive_filter(self): + m = pe.ConcreteModel() + m.y = pe.Var() + m.x = pe.Var(bounds=(-2, -1)) + m.obj = pe.Objective(expr=m.y) + m.c = pe.Constraint(expr=m.y == -m.x**2) + r = coramin.relaxations.relax(m) + opt = appsi.solvers.Highs() + vars_to_min, vars_to_max = coramin.domain_reduction.aggressive_filter( + candidate_variables=[m.x], relaxation=r, solver=opt + ) + self.assertNotIn(m.x, vars_to_max) + self.assertNotIn(m.x, vars_to_min) diff --git a/pyomo/contrib/coramin/domain_reduction/tests/test_obbt.py b/pyomo/contrib/coramin/domain_reduction/tests/test_obbt.py new file mode 100644 index 00000000000..bae429b3e9c --- /dev/null +++ b/pyomo/contrib/coramin/domain_reduction/tests/test_obbt.py @@ -0,0 +1,127 @@ +from pyomo.contrib import coramin +from pyomo.common import unittest +import pyomo.environ as pyo +from pyomo.contrib import appsi + + +ipopt_available = appsi.solvers.Ipopt().available() + + +@unittest.skipUnless(ipopt_available, 'ipopt is not available') +class TestBoundsTightener(unittest.TestCase): + @classmethod + def setUpClass(cls): + pass + + @classmethod + def tearDownClass(cls): + pass + + def test_quad(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(bounds=(-5.0, 5.0)) + model.y = pyo.Var(bounds=(-100.0, 100.0)) + + model.obj_expr = pyo.Expression(expr=model.y) + model.obj = pyo.Objective(expr=model.obj_expr) + + x_points = [-5.0, 5.0] + model.under_estimators = pyo.ConstraintList() + for xp in x_points: + m = 2 * xp + b = -(xp**2) + model.under_estimators.add(model.y >= m * model.x + b) + + solver = appsi.solvers.Ipopt() + (lower, upper) = coramin.domain_reduction.perform_obbt( + model=model, solver=solver, varlist=[model.x, model.y], update_bounds=True + ) + self.assertAlmostEqual(pyo.value(model.x.lb), -5.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.x.ub), 5.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.y.lb), -25.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.y.ub), 100.0, delta=1e-6) + self.assertAlmostEqual(lower[0], -5.0, delta=1e-6) + self.assertAlmostEqual(upper[0], 5.0, delta=1e-6) + self.assertAlmostEqual(lower[1], -25.0, delta=1e-6) + self.assertAlmostEqual(upper[1], 100.0, delta=1e-6) + + def test_passing_component_not_list(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(bounds=(-5.0, 5.0)) + model.y = pyo.Var(bounds=(-100.0, 100.0)) + + model.obj_expr = pyo.Expression(expr=model.y) + model.obj = pyo.Objective(expr=model.obj_expr) + + x_points = [-5.0, 5.0] + model.under_estimators = pyo.ConstraintList() + for xp in x_points: + m = 2 * xp + b = -(xp**2) + model.under_estimators.add(model.y >= m * model.x + b) + + solver = appsi.solvers.Ipopt() + (lower, upper) = coramin.domain_reduction.perform_obbt( + model=model, solver=solver, varlist=model.y, update_bounds=True + ) + self.assertAlmostEqual(pyo.value(model.x.lb), -5.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.x.ub), 5.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.y.lb), -25.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.y.ub), 100.0, delta=1e-6) + self.assertAlmostEqual(lower[0], -25.0, delta=1e-6) + self.assertAlmostEqual(upper[0], 100.0, delta=1e-6) + + def test_passing_indexed_component_not_list(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(bounds=(-5.0, 5.0)) + model.S = pyo.Set(initialize=['A', 'B'], ordered=True) + model.y = pyo.Var(model.S, bounds=(-100.0, 100.0)) + + model.obj_expr = pyo.Expression(expr=model.y['A']) + model.obj = pyo.Objective(expr=model.obj_expr) + + x_points = [-5.0, 5.0] + model.under_estimators = pyo.ConstraintList() + for xp in x_points: + m = 2 * xp + b = -(xp**2) + model.under_estimators.add(model.y['A'] >= m * model.x + b) + + model.con = pyo.Constraint(expr=model.y['A'] == 1 + model.y['B']) + + solver = appsi.solvers.Ipopt() + lower, upper = coramin.domain_reduction.perform_obbt( + model=model, solver=solver, varlist=model.y, update_bounds=True + ) + self.assertAlmostEqual(pyo.value(model.x.lb), -5.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.x.ub), 5.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.y['A'].lb), -25.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.y['A'].ub), 100.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.y['B'].lb), -26.0, delta=1e-6) + self.assertAlmostEqual(pyo.value(model.y['B'].ub), 99.0, delta=1e-6) + self.assertAlmostEqual(lower[0], -25.0, delta=1e-6) + self.assertAlmostEqual(upper[0], 100.0, delta=1e-6) + self.assertAlmostEqual(lower[1], -26.0, delta=1e-6) + self.assertAlmostEqual(upper[1], 99.0, delta=1e-6) + + def test_too_many_obj(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(bounds=(-5.0, 5.0)) + model.y = pyo.Var(bounds=(-100.0, 100.0)) + + model.obj1 = pyo.Objective(expr=model.x + model.y) + model.obj2 = pyo.Objective(expr=model.x - model.y) + + solver = pyo.SolverFactory('ipopt') + with self.assertRaises(ValueError): + coramin.domain_reduction.perform_obbt( + model=model, + solver=solver, + varlist=[model.x, model.y], + objective_bound=0.0, + update_bounds=True, + ) + + +if __name__ == '__main__': + TestBoundsTightener() diff --git a/pyomo/contrib/coramin/examples/__init__.py b/pyomo/contrib/coramin/examples/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/examples/alpha_bb.py b/pyomo/contrib/coramin/examples/alpha_bb.py new file mode 100644 index 00000000000..b14390d9eac --- /dev/null +++ b/pyomo/contrib/coramin/examples/alpha_bb.py @@ -0,0 +1,49 @@ +import pyomo.environ as pe +from pyomo.contrib import coramin +from pyomo.contrib.coramin.utils.plot_relaxation import plot_relaxation +from pyomo.contrib import appsi + + +def main(): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(0.001, 2)) + m.y = pe.Var(bounds=(0.01, 10)) + m.z = pe.Var() + m.c = coramin.relaxations.AlphaBBRelaxation() + + m.c.build( + aux_var=m.z, + f_x_expr=m.x * pe.log(m.x / m.y), + relaxation_side=coramin.RelaxationSide.UNDER, + eigenvalue_bounder=coramin.EigenValueBounder.GershgorinWithSimplification, + ) + m.x.value = m.x.lb + m.y.value = m.y.ub + m.c.add_cut(keep_cut=True, check_violation=False) + m.x.value = m.x.ub + m.y.value = m.y.lb + m.c.add_cut(keep_cut=True, check_violation=False) + + opt = pe.SolverFactory('gurobi_persistent') + opt.set_instance(m) + plot_relaxation(m, m.c, opt) + + m.c.hessian.method = coramin.EigenValueBounder.LinearProgram + m.c.hessian.opt = appsi.solvers.Gurobi() + m.c.rebuild() + plot_relaxation(m, m.c, opt) + + m.c.hessian.method = coramin.EigenValueBounder.Global + mip_opt = appsi.solvers.Gurobi() + nlp_opt = appsi.solvers.Ipopt() + eigenvalue_opt = coramin.algorithms.MultiTree( + mip_solver=mip_opt, nlp_solver=nlp_opt + ) + eigenvalue_opt.config.convexity_effort = 'medium' + m.c.hessian.opt = eigenvalue_opt + m.c.rebuild() + plot_relaxation(m, m.c, opt) + + +if __name__ == '__main__': + main() diff --git a/pyomo/contrib/coramin/examples/dbt.py b/pyomo/contrib/coramin/examples/dbt.py new file mode 100644 index 00000000000..bf3e9c0eadc --- /dev/null +++ b/pyomo/contrib/coramin/examples/dbt.py @@ -0,0 +1,115 @@ +""" +This example demonstrates how to used decomposed bounds +tightening. The example problem is an ACOPF problem. +""" + +import pyomo.environ as pe +from pyomo.contrib import coramin +from egret.data.model_data import ModelData +from egret.thirdparty.get_pglib_opf import get_pglib_opf +from egret.models.ac_relaxations import create_polar_acopf_relaxation +from egret.models.acopf import create_psv_acopf_model +import itertools +import os +import time + + +# Create the NLP and the relaxation +print('Downloading Power Grid Lib') +if not os.path.exists('pglib-opf-master'): + get_pglib_opf() + +print('Creating NLP and relaxation') +md = ModelData.read('pglib-opf-master/api/pglib_opf_case73_ieee_rts__api.m') +nlp, scaled_md = create_psv_acopf_model(md) +relaxation, scaled_md2 = create_polar_acopf_relaxation(md) + +# perform decomposition +print('Decomposing relaxation') +(relaxation, component_map, termination_reason) = ( + coramin.domain_reduction.decompose_model(relaxation, max_leaf_nnz=1000) +) + +# Add more outer approximation points for the second order cone constraints +print('Adding extra outer-approximation points for SOC constraints') +for b in coramin.relaxations.relaxation_data_objects( + relaxation, descend_into=True, active=True, sort=True +): + if isinstance(b, coramin.relaxations.MultivariateRelaxationData): + b.clear_oa_points() + for bnd_combination in itertools.product( + *[itertools.product(['L', 'U'], [v]) for v in b.get_rhs_vars()] + ): + bnd_dict = pe.ComponentMap() + for lower_or_upper, v in bnd_combination: + if lower_or_upper == 'L': + if v.has_lb(): + bnd_dict[v] = v.lb + else: + bnd_dict[v] = -1 + else: + assert lower_or_upper == 'U' + if v.has_ub(): + bnd_dict[v] = v.ub + else: + bnd_dict[v] = 1 + b.add_oa_point(var_values=bnd_dict) + +# rebuild the relaxations +for b in coramin.relaxations.relaxation_data_objects( + relaxation, descend_into=True, active=True, sort=True +): + b.rebuild() + +# create solvers +nlp_opt = pe.SolverFactory('ipopt') +rel_opt = pe.SolverFactory('gurobi_persistent') + +# solve the nlp to get the upper bound +print('Solving NLP') +res = nlp_opt.solve(nlp) +assert res.solver.termination_condition == pe.TerminationCondition.optimal +ub = pe.value(coramin.utils.get_objective(nlp)) + +# solve the relaxation to get the lower bound +print('Solving relaxation') +rel_opt.set_instance(relaxation) +res = rel_opt.solve(save_results=False) +assert res.solver.termination_condition == pe.TerminationCondition.optimal +lb = pe.value(coramin.utils.get_objective(relaxation)) +gap = (ub - lb) / ub * 100 +print( + '{ub:<20}{lb:<20}{gap:<20}{time:<20}'.format( + ub='UB', lb='LB', gap='% gap', time='Time' + ) +) +t0 = time.time() +print( + '{ub:<20.2f}{lb:<20.2f}{gap:<20.2f}{time:<20.2f}'.format( + ub=ub, lb=lb, gap=gap, time=time.time() - t0 + ) +) + +for _iter in range(3): + coramin.domain_reduction.perform_dbt( + relaxation=relaxation, + solver=rel_opt, + obbt_method=coramin.domain_reduction.OBBTMethod.DECOMPOSED, + filter_method=coramin.domain_reduction.FilterMethod.AGGRESSIVE, + objective_bound=ub, + with_progress_bar=True, + ) + for r in coramin.relaxations.relaxation_data_objects( + relaxation, descend_into=True, active=True, sort=True + ): + r.rebuild() + rel_opt.set_instance(relaxation) + res = rel_opt.solve(save_results=False) + assert res.solver.termination_condition == pe.TerminationCondition.optimal + lb = pe.value(coramin.utils.get_objective(relaxation)) + gap = (ub - lb) / ub * 100 + print( + '{ub:<20.2f}{lb:<20.2f}{gap:<20.2f}{time:<20.2f}'.format( + ub=ub, lb=lb, gap=gap, time=time.time() - t0 + ) + ) diff --git a/pyomo/contrib/coramin/examples/dbt2.py b/pyomo/contrib/coramin/examples/dbt2.py new file mode 100644 index 00000000000..57a62caab02 --- /dev/null +++ b/pyomo/contrib/coramin/examples/dbt2.py @@ -0,0 +1,111 @@ +""" +This example demonstrates how to used decomposed bounds +tightening. The example problem is from minlplib. In order to run +this example, you have to download the problem file corresponding to +the filename in the "read_osil" function bedlow. The file can be +downloaded from minlplib.org. Suspect is also needed. +""" + +import pyomo.environ as pe +from pyomo.contrib import coramin +import itertools +import os +import time +from suspect.pyomo import read_osil +from pyomo.contrib.coramin.third_party.minlplib_tools import get_minlplib + + +print('Downloading camshape800 from MINLPLib') +if not os.path.exists(os.path.join('minlplib', 'osil', 'camshape800.osil')): + get_minlplib(problem_name='camshape800') + +print('Creating NLP and relaxation') +nlp = read_osil( + 'minlplib/osil/camshape800.osil', objective_prefix='obj_', constraint_prefix='con_' +) +relaxation = coramin.relaxations.relax( + nlp, + in_place=False, + use_fbbt=True, + fbbt_options={'deactivate_satisfied_constraints': True, 'max_iter': 2}, +) + +# perform decomposition +print('Decomposing relaxation') +(relaxation, component_map, termination_reason) = ( + coramin.domain_reduction.decompose_model(relaxation, max_leaf_nnz=1000) +) + +# rebuild the relaxations +for b in coramin.relaxations.relaxation_data_objects( + relaxation, descend_into=True, active=True, sort=True +): + b.rebuild() + +# create solvers +nlp_opt = pe.SolverFactory('ipopt') +rel_opt = pe.SolverFactory('gurobi_persistent') + +# solve the nlp to get the upper bound +print('Solving NLP') +res = nlp_opt.solve(nlp) +assert res.solver.termination_condition == pe.TerminationCondition.optimal +ub = pe.value(coramin.utils.get_objective(nlp)) + +# solve the relaxation to get the lower bound +print('Solving relaxation') +rel_opt.set_instance(relaxation) +res = rel_opt.solve(save_results=False) +assert res.solver.termination_condition == pe.TerminationCondition.optimal +lb = pe.value(coramin.utils.get_objective(relaxation)) +gap = (ub - lb) / abs(ub) * 100 +var_bounds = pe.ComponentMap() +for r in coramin.relaxations.relaxation_data_objects( + relaxation, descend_into=True, active=True, sort=True +): + for v in r.get_rhs_vars(): + var_bounds[v] = v.ub - v.lb +avg_bound_range = sum(var_bounds.values()) / len(var_bounds) +print( + '{ub:<20}{lb:<20}{gap:<20}{avg_rng:<20}{time:<20}'.format( + ub='UB', lb='LB', gap='% gap', avg_rng='Avg Var Range', time='Time' + ) +) +t0 = time.time() +print( + '{ub:<20.3f}{lb:<20.3f}{gap:<20.3f}{avg_rng:<20.3e}{time:<20.3f}'.format( + ub=ub, lb=lb, gap=gap, avg_rng=avg_bound_range, time=time.time() - t0 + ) +) + +# Perform bounds tightening +for _iter in range(3): + coramin.domain_reduction.perform_dbt( + relaxation=relaxation, + solver=rel_opt, + obbt_method=coramin.domain_reduction.OBBTMethod.DECOMPOSED, + filter_method=coramin.domain_reduction.FilterMethod.AGGRESSIVE, + objective_bound=ub, + with_progress_bar=True, + ) + for r in coramin.relaxations.relaxation_data_objects( + relaxation, descend_into=True, active=True, sort=True + ): + r.rebuild() + rel_opt.set_instance(relaxation) + res = rel_opt.solve(save_results=False) + assert res.solver.termination_condition == pe.TerminationCondition.optimal + lb = pe.value(coramin.utils.get_objective(relaxation)) + gap = (ub - lb) / abs(ub) * 100 + var_bounds = pe.ComponentMap() + for r in coramin.relaxations.relaxation_data_objects( + relaxation, descend_into=True, active=True, sort=True + ): + for v in r.get_rhs_vars(): + var_bounds[v] = v.ub - v.lb + avg_bound_range = sum(var_bounds.values()) / len(var_bounds) + print( + '{ub:<20.3f}{lb:<20.3f}{gap:<20.3f}{avg_rng:<20.3e}{time:<20.3f}'.format( + ub=ub, lb=lb, gap=gap, avg_rng=avg_bound_range, time=time.time() - t0 + ) + ) diff --git a/pyomo/contrib/coramin/examples/ex.py b/pyomo/contrib/coramin/examples/ex.py new file mode 100644 index 00000000000..6fb8252dc5c --- /dev/null +++ b/pyomo/contrib/coramin/examples/ex.py @@ -0,0 +1,90 @@ +import pyomo.environ as pe +from pyomo.contrib import coramin +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr + + +""" +This example demonstrates a couple features of Coramin: +- Using the "add_cut" methods to refine relaxations with linear constraints +- Optimization-based bounds tightening + +The example problem is + +min x**4 - 3*x**2 + x +""" + + +# Build and solve the NLP +nlp = pe.ConcreteModel() +nlp.x = pe.Var(bounds=(-2, 2)) +nlp.obj = pe.Objective(expr=nlp.x**4 - 3 * nlp.x**2 + nlp.x) +opt = pe.SolverFactory('ipopt') +res = opt.solve(nlp) +ub = pe.value(nlp.obj) + +# Build the relaxation +""" +Reformulate the NLP as + +min x4 - 3*x2 + x +s.t. + x2 = x**2 + x4 = x2**2 + +Then relax the two constraints with PWXSquaredRelaxation objects. +""" +rel = pe.ConcreteModel() +rel.x = pe.Var(bounds=(-2, 2)) +rel.x2 = pe.Var(bounds=compute_bounds_on_expr(rel.x**2)) +rel.x4 = pe.Var(bounds=compute_bounds_on_expr(rel.x2**2)) +rel.x2_con = coramin.relaxations.PWXSquaredRelaxation() +rel.x2_con.build(x=rel.x, aux_var=rel.x2, use_linear_relaxation=True) +rel.x4_con = coramin.relaxations.PWXSquaredRelaxation() +rel.x4_con.build(x=rel.x2, aux_var=rel.x4, use_linear_relaxation=True) +rel.obj = pe.Objective(expr=rel.x4 - 3 * rel.x2 + rel.x) + + +# Now solve the relaxation and refine the convex sides of the constraints with add_cut +print('*********************************') +print('OA Cut Generation') +print('*********************************') +opt = pe.SolverFactory('gurobi_direct') +res = opt.solve(rel) +lb = pe.value(rel.obj) +print('gap: ' + str(100 * abs(ub - lb) / abs(ub)) + ' %') + +for _iter in range(10): + for b in rel.component_data_objects( + pe.Block, active=True, sort=True, descend_into=True + ): + if isinstance(b, coramin.relaxations.BaseRelaxationData): + b.add_cut() + res = opt.solve(rel) + lb = pe.value(rel.obj) + print('gap: ' + str(100 * abs(ub - lb) / abs(ub)) + ' %') + +# we want to discard the cuts generated above just to demonstrate OBBT +for b in rel.component_data_objects( + pe.Block, active=True, sort=True, descend_into=True +): + if isinstance(b, coramin.relaxations.BasePWRelaxationData): + b.clear_oa_points() + b.rebuild() + +# Now refine the relaxation with OBBT +print('\n*********************************') +print('OBBT') +print('*********************************') +res = opt.solve(rel) +lb = pe.value(rel.obj) +print('gap: ' + str(100 * abs(ub - lb) / abs(ub)) + ' %') +for _iter in range(10): + coramin.domain_reduction.perform_obbt(rel, opt, [rel.x, rel.x2], objective_bound=ub) + for b in rel.component_data_objects( + pe.Block, active=True, sort=True, descend_into=True + ): + if isinstance(b, coramin.relaxations.BasePWRelaxationData): + b.rebuild() + res = opt.solve(rel) + lb = pe.value(rel.obj) + print('gap: ' + str(100 * abs(ub - lb) / abs(ub)) + ' %') diff --git a/pyomo/contrib/coramin/examples/rosenbrock.py b/pyomo/contrib/coramin/examples/rosenbrock.py new file mode 100644 index 00000000000..cf55ce967d6 --- /dev/null +++ b/pyomo/contrib/coramin/examples/rosenbrock.py @@ -0,0 +1,63 @@ +import pyomo.environ as pe +from pyomo.contrib import coramin + + +def create_nlp(a, b): + # Create the nlp + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-20.0, 20.0)) + m.y = pe.Var(bounds=(-20.0, 20.0)) + + m.objective = pe.Objective(expr=(a - m.x) ** 2 + b * (m.y - m.x**2) ** 2) + + return m + + +def create_relaxation(a, b): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-20.0, 20.0)) + m.x_sq = pe.Var() + m.y = pe.Var(bounds=(-20.0, 20.0)) + m.z = pe.Var() + + m.objective = pe.Objective(expr=(a - m.x) ** 2 + b * m.z**2) + m.con1 = pe.Constraint(expr=m.z == m.y - m.x_sq) + m.x_sq_con = coramin.relaxations.PWXSquaredRelaxation() + m.x_sq_con.build(x=m.x, aux_var=m.x_sq, use_linear_relaxation=True) + + return m + + +def main(): + a = 1 + b = 1 + nlp = create_nlp(a, b) + rel = create_relaxation(a, b) + + nlp_opt = pe.SolverFactory('ipopt') + rel_opt = pe.SolverFactory('gurobi_direct') + + res = nlp_opt.solve(nlp, tee=False) + assert res.solver.termination_condition == pe.TerminationCondition.optimal + ub = pe.value(nlp.objective) + + res = rel_opt.solve(rel, tee=False) + assert res.solver.termination_condition == pe.TerminationCondition.optimal + lb = pe.value(rel.objective) + + print('lb: ', lb) + print('ub: ', ub) + + print('nlp results:') + print('--------------------------') + nlp.x.pprint() + nlp.y.pprint() + + print('relaxation results:') + print('--------------------------') + rel.x.pprint() + rel.y.pprint() + + +if __name__ == '__main__': + main() diff --git a/pyomo/contrib/coramin/heuristics/__init__.py b/pyomo/contrib/coramin/heuristics/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/heuristics/binary_multiplication_reformulation.py b/pyomo/contrib/coramin/heuristics/binary_multiplication_reformulation.py new file mode 100644 index 00000000000..ae57e148429 --- /dev/null +++ b/pyomo/contrib/coramin/heuristics/binary_multiplication_reformulation.py @@ -0,0 +1,356 @@ +import pyomo.environ as pe +from pyomo.core.base.block import _BlockData +from pyomo.core.base.var import _GeneralVarData, ScalarVar +from pyomo.core.base.param import _ParamData, ScalarParam +from pyomo.core.base.expression import _GeneralExpressionData, ScalarExpression +from pyomo.core.expr import numeric_expr +from typing import Sequence +from pyomo.core.expr.visitor import StreamBasedExpressionVisitor +from pyomo.core.expr.numvalue import NumericValue, native_numeric_types +from typing import Union, Sequence +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr +from pyomo.contrib.coramin.relaxations.mccormick import PWMcCormickRelaxation +from pyomo.contrib.coramin.utils.coramin_enums import RelaxationSide +from pyomo.contrib.coramin.relaxations import iterators +from pyomo.repn.standard_repn import generate_standard_repn +from pyomo.gdp.disjunct import AutoLinkedBinaryVar + + +class BinaryMultiplicationInfo(object): + def __init__(self, m: _BlockData) -> None: + self.m = m + self.root_node = None + self.constraint_bounds = None + + +def handle_var( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + if node.is_fixed(): + res = node.value + else: + res = node + return res + + +def handle_float( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return node + + +def handle_param( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return node.value + + +def handle_sum( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return sum(args) + + +def handle_monomial( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return args[0] * args[1] + + +def handle_product( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + x, y = args + xtype = type(x) + ytype = type(y) + + if xtype in native_numeric_types or ytype in native_numeric_types: + return x * y + if (x.is_variable_type() and x.is_binary()) or ( + y.is_variable_type() and y.is_binary() + ): + + def get_new_rel(m): + ndx = len(m.relaxations) + new_rel = PWMcCormickRelaxation() + setattr(m, f'rel{ndx}', new_rel) + m.relaxations.append(new_rel) + return new_rel + + if x.is_variable_type(): + _x = x + else: + _x = info.m.vars.add() + info.m.cons.add(_x == x) + if y.is_variable_type(): + _y = y + else: + _y = info.vars.add() + info.m.cons.add(_y == y) + if info.root_node is node: + clb, cub = info.constraint_bounds + if clb == cub and clb is not None: + rel = get_new_rel(info.m) + rel.build( + _x, _y, clb, relaxation_side=RelaxationSide.BOTH, safety_tol=0 + ) + else: + if clb is not None: + rel = get_new_rel(info.m) + rel.build( + _x, _y, clb, relaxation_side=RelaxationSide.OVER, safety_tol=0 + ) + if cub is not None: + rel = get_new_rel(info.m) + rel.build( + _x, _y, cub, relaxation_side=RelaxationSide.UNDER, safety_tol=0 + ) + return None + else: + z = info.m.vars.add() + zlb, zub = compute_bounds_on_expr(node) + z.setlb(zlb) + z.setub(zub) + rel = get_new_rel(info.m) + rel.build(_x, _y, z, relaxation_side=RelaxationSide.BOTH, safety_tol=0) + return z + return x * y + + +def handle_exp( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.exp(args[0]) + + +def handle_log( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.log(args[0]) + + +def handle_log10( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.log10(args[0]) + + +def handle_sin( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.sin(args[0]) + + +def handle_cos( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.cos(args[0]) + + +def handle_tan( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.tan(args[0]) + + +def handle_asin( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.asin(args[0]) + + +def handle_acos( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.acos(args[0]) + + +def handle_atan( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.atan(args[0]) + + +def handle_sqrt( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.sqrt(args[0]) + + +def handle_abs( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return pe.abs(args[0]) + + +def handle_div( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + x, y = args + return x / y + + +def handle_pow( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + x, y = args + return x**y + + +def handle_negation( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return -args[0] + + +def handle_named_expression( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return args[0] + + +unary_handlers = dict() +unary_handlers['exp'] = handle_exp +unary_handlers['log'] = handle_log +unary_handlers['log10'] = handle_log10 +unary_handlers['sin'] = handle_sin +unary_handlers['cos'] = handle_cos +unary_handlers['tan'] = handle_tan +unary_handlers['asin'] = handle_asin +unary_handlers['acos'] = handle_acos +unary_handlers['atan'] = handle_atan +unary_handlers['sqrt'] = handle_sqrt +unary_handlers['abs'] = handle_abs + + +def handle_unary( + node: Union[NumericValue, float], + args: Sequence[Union[NumericValue, float]], + info: BinaryMultiplicationInfo, +): + return unary_handlers[node.getname()](node, args, info) + + +handlers = dict() +handlers[_GeneralVarData] = handle_var +handlers[ScalarVar] = handle_var +handlers[AutoLinkedBinaryVar] = handle_var +handlers[_ParamData] = handle_param +handlers[ScalarParam] = handle_param +handlers[float] = handle_float +handlers[int] = handle_float +handlers[numeric_expr.SumExpression] = handle_sum +handlers[numeric_expr.LinearExpression] = handle_sum +handlers[numeric_expr.MonomialTermExpression] = handle_monomial +handlers[numeric_expr.ProductExpression] = handle_product +handlers[numeric_expr.DivisionExpression] = handle_div +handlers[numeric_expr.PowExpression] = handle_pow +handlers[numeric_expr.NegationExpression] = handle_negation +handlers[numeric_expr.UnaryFunctionExpression] = handle_unary +handlers[numeric_expr.AbsExpression] = handle_abs +handlers[_GeneralExpressionData] = handle_named_expression +handlers[ScalarExpression] = handle_named_expression +handlers[numeric_expr.NPV_SumExpression] = handle_sum +handlers[numeric_expr.NPV_ProductExpression] = handle_product +handlers[numeric_expr.NPV_DivisionExpression] = handle_div +handlers[numeric_expr.NPV_PowExpression] = handle_pow +handlers[numeric_expr.NPV_NegationExpression] = handle_negation +handlers[numeric_expr.NPV_UnaryFunctionExpression] = handle_unary +handlers[numeric_expr.NPV_AbsExpression] = handle_abs + + +class BinaryMultiplicationWalker(StreamBasedExpressionVisitor): + def __init__(self, m: _BlockData): + super().__init__() + self.info = BinaryMultiplicationInfo(m) + + def exitNode(self, node, data): + return handlers[node.__class__](node, data, self.info) + + +def reformulate_binary_multiplication(m: _BlockData): + """ + The goal of this function is to replace f(x) * y = 0 with + a McCormick relaxation when y is binary (in which case the + McCormick relaxation is equivalent). + """ + r = pe.ConcreteModel() + r.vars = pe.VarList() + r.cons = pe.ConstraintList() + r.relaxations = list() + + walker = BinaryMultiplicationWalker(r) + info = walker.info + + for c in iterators.nonrelaxation_component_data_objects( + m, pe.Constraint, active=True, descend_into=True + ): + repn = generate_standard_repn(c.body, compute_values=True, quadratic=False) + if repn.nonlinear_expr is None: + r.cons.add((c.lb, c.body, c.ub)) + elif not any(v.is_binary() for v in repn.nonlinear_vars): + r.cons.add((c.lb, c.body, c.ub)) + else: + info.root_node = c.body + info.constraint_bounds = (c.lb, c.ub) + new_body = walker.walk_expression(c.body) + if new_body is not None: + r.cons.add((c.lb, new_body, c.ub)) + + for obj in iterators.nonrelaxation_component_data_objects( + m, pe.Objective, active=True, descend_into=True + ): + repn = generate_standard_repn(obj.expr, compute_values=True, quadratic=False) + if repn.nonlinear_expr is None: + r.obj = pe.Objective(expr=obj.expr, sense=obj.sense) + elif not any(v.is_binary() for v in repn.nonlinear_vars): + r.obj = pe.Objective(expr=obj.expr, sense=obj.sense) + else: + info.root_node = None + info.constraint_bounds = None + new_expr = walker.walk_expression(obj.expr) + r.obj = pe.Objective(expr=new_expr, sense=obj.sense) + + return r diff --git a/pyomo/contrib/coramin/heuristics/diving.py b/pyomo/contrib/coramin/heuristics/diving.py new file mode 100644 index 00000000000..d1d19b6aa69 --- /dev/null +++ b/pyomo/contrib/coramin/heuristics/diving.py @@ -0,0 +1,327 @@ +import pyomo.environ as pe +import pybnb +from pyomo.core.base.block import _BlockData +from pyomo.core.base.var import _GeneralVarData +from typing import Tuple, List, Sequence, Optional, MutableMapping +from pyomo.contrib import appsi +from pyomo.contrib.coramin.utils.pyomo_utils import get_objective +from pyomo.common.dependencies import numpy as np +import math +from pyomo.common.collections import ComponentSet, ComponentMap +from pyomo.core.expr.visitor import identify_variables +from pyomo.contrib.appsi.fbbt import IntervalTightener, InfeasibleConstraintException +from typing import Sequence +from .binary_multiplication_reformulation import reformulate_binary_multiplication +from pyomo.contrib.coramin.clone import clone_shallow_active_flat +from pyomo.contrib.coramin.relaxations import iterators + + +def collect_vars( + m: _BlockData, +) -> Tuple[List[_GeneralVarData], List[_GeneralVarData], List[_GeneralVarData]]: + binary_vars = ComponentSet() + integer_vars = ComponentSet() + all_vars = ComponentSet() + for c in m.component_data_objects( + pe.Constraint, active=True, descend_into=pe.Block + ): + for v in identify_variables(c.body, include_fixed=False): + all_vars.add(v) + if v.is_binary(): + binary_vars.add(v) + elif v.is_integer(): + integer_vars.add(v) + obj = get_objective(m) + if obj is not None: + for v in identify_variables(obj.expr, include_fixed=False): + all_vars.add(v) + if v.is_binary(): + binary_vars.add(v) + elif v.is_integer(): + integer_vars.add(v) + return list(binary_vars), list(integer_vars), list(all_vars) + + +def relax_integers( + binary_vars: Sequence[_GeneralVarData], integer_vars: Sequence[_GeneralVarData] +): + for v in list(binary_vars) + list(integer_vars): + lb, ub = v.bounds + v.domain = pe.Reals + v.setlb(lb) + v.setub(ub) + + +def restore_integers( + binary_vars: Sequence[_GeneralVarData], integer_vars: Sequence[_GeneralVarData] +): + for v in binary_vars: + v.domain = pe.Binary + for v in integer_vars: + v.domain = pe.Integers + + +class DivingHeuristic(pybnb.Problem): + def __init__(self, m: _BlockData) -> None: + super().__init__() + + binary_vars, integer_vars, all_vars = collect_vars(m) + self.relaxation = clone_shallow_active_flat( + reformulate_binary_multiplication(m) + )[0] + + orig_lbs = [v.lb for v in self.relaxation.vars] + orig_ubs = [v.ub for v in self.relaxation.vars] + for r in iterators.relaxation_data_objects( + self.relaxation, descend_into=True, active=True + ): + r.rebuild(build_nonlinear_constraint=True) + tightener = IntervalTightener() + tightener.config.deactivate_satisfied_constraints = False + tightener.perform_fbbt(self.relaxation) + self.tight_lbs = [v.lb for v in self.relaxation.vars] + self.tight_ubs = [v.ub for v in self.relaxation.vars] + for v, lb, ub in zip(self.relaxation.vars, orig_lbs, orig_ubs): + v.setlb(lb) + v.setub(ub) + + relax_integers(binary_vars, integer_vars) + + self.m = m + self.tightener = IntervalTightener() + self.tightener.config.deactivate_satisfied_constraints = False + self.all_vars = all_vars + self.binary_vars = binary_vars + self.integer_vars = integer_vars + self.bin_and_int_vars = list(binary_vars) + list(integer_vars) + self.orig_lbs = [v.lb for v in self.bin_and_int_vars] + self.orig_ubs = [v.ub for v in self.bin_and_int_vars] + self.obj = get_objective(m) + + if self.obj.sense == pe.minimize: + self._sense = pybnb.minimize + else: + self._sense = pybnb.maximize + + self.current_node: Optional[pybnb.Node] = None + + def sense(self): + return self._sense + + def bound(self): + orig_lbs = [v.lb for v in self.relaxation.vars] + orig_ubs = [v.ub for v in self.relaxation.vars] + + for v, lb, ub in zip(self.relaxation.vars, self.tight_lbs, self.tight_ubs): + assert lb is None or math.isfinite(lb) + assert ub is None or math.isfinite(ub) + if v.lb is None or (lb is not None and lb > v.lb): + v.setlb(lb) + if v.ub is None or (ub is not None and ub < v.ub): + v.setub(ub) + + for r in iterators.relaxation_data_objects( + self.relaxation, descend_into=True, active=True + ): + r.rebuild() + + for v, lb, ub in zip(self.relaxation.vars, orig_lbs, orig_ubs): + v.setlb(lb) + v.setub(ub) + + opt = pe.SolverFactory('ipopt') + try: + res = opt.solve( + self.relaxation, + skip_trivial_constraints=True, + load_solutions=False, + tee=False, + ) + except: + return self.infeasible_objective() + if not pe.check_optimal_termination(res): + return self.infeasible_objective() + self.relaxation.solutions.load_from(res) + ret = pe.value(self.obj.expr) + if self._sense == pybnb.minimize: + ret = max(self.current_node.bound, ret) + else: + ret = min(self.current_node.bound, ret) + return ret + + def objective(self): + unfixed_vars = [v for v in self.bin_and_int_vars if not v.is_fixed()] + vals = [v.value for v in unfixed_vars] + for v in unfixed_vars: + v.fix(round(v.value)) + orig_bounds = [v.bounds for v in self.all_vars] + success = True + try: + self.tightener.perform_fbbt(self.m) + except InfeasibleConstraintException: + success = False + for v, (lb, ub) in zip(self.all_vars, orig_bounds): + v.setlb(lb) + v.setub(ub) + if success: + opt = pe.SolverFactory('ipopt') + opt.options['max_iter'] = 300 + try: + res = opt.solve( + self.m, + skip_trivial_constraints=True, + load_solutions=False, + tee=False, + ) + except: + success = False + + if not success: + ret = self.infeasible_objective() + elif not pe.check_optimal_termination(res): + ret = self.infeasible_objective() + else: + self.m.solutions.load_from(res) + ret = pe.value(self.obj.expr) + sol = np.array([v.value for v in self.all_vars], dtype=float) + xl, xu, _ = self.current_node.state + self.current_node.state = (xl, xu, sol) + for v, val in zip(unfixed_vars, vals): + v.unfix() + # we have to restore the values so that branch() works properly + v.set_value(val, skip_validation=True) + return ret + + def get_state(self): + xl = [math.ceil(v.lb) for v in self.bin_and_int_vars] + xl = np.array(xl, dtype=int) + + xu = [math.floor(v.ub) for v in self.bin_and_int_vars] + xu = np.array(xu, dtype=int) + + return xl, xu, None + + def save_state(self, node): + node.state = self.get_state() + + def load_state(self, node): + self.current_node = node + xl, xu, _ = node.state + xl = [int(i) for i in xl] + xu = [int(i) for i in xu] + + for v, lb, ub in zip(self.bin_and_int_vars, xl, xu): + v.setlb(lb) + v.setub(ub) + if lb == ub: + v.fix(lb) + else: + v.unfix() + + def branch(self): + if len(self.bin_and_int_vars) == 0: + return pybnb.Node() + + xl, xu, _ = self.get_state() + dist_list = [ + (abs(v.value - round(v.value)), ndx) + for ndx, v in enumerate(self.bin_and_int_vars) + ] + dist_list.sort(key=lambda i: i[0], reverse=True) + ndx = dist_list[0][1] + branching_var = self.bin_and_int_vars[ndx] + + xl1 = xl.copy() + xu1 = xu.copy() + xu1[ndx] = math.floor(branching_var.value) + child1 = pybnb.Node() + child1.state = (xl1, xu1, None) + + xl2 = xl.copy() + xu2 = xu.copy() + xl2[ndx] = math.ceil(branching_var.value) + child2 = pybnb.Node() + child2.state = (xl2, xu2, None) + + yield child1 + yield child2 + + +def assert_feasible( + m: _BlockData, + var_list: Sequence[_GeneralVarData], + feasibility_tol: float, + integer_tol: float, +): + for c in m.component_data_objects( + pe.Constraint, active=True, descend_into=pe.Block + ): + body_val = pe.value(c.body) + if c.lb is not None: + assert ( + c.lb - feasibility_tol <= body_val + or abs(c.lb - body_val) / abs(c.lb) <= feasibility_tol + ) + if c.ub is not None: + assert ( + body_val <= c.ub + feasibility_tol + or abs(c.ub - body_val) / abs(c.ub) <= feasibility_tol + ) + + for v in var_list: + val = v.value + lb, ub = v.bounds + if lb is not None: + assert ( + lb - feasibility_tol <= val + or abs(lb - val) / abs(lb) <= feasibility_tol + ) + if ub is not None: + assert ( + val <= ub + feasibility_tol + or abs(ub - val) / abs(ub) <= feasibility_tol + ) + if v.is_integer(): + assert abs(val - round(val)) <= integer_tol + + +def run_diving_heuristic( + m: _BlockData, + feasibility_tol: float = 1e-6, + integer_tol: float = 1e-4, + time_limit: float = 300, + node_limit: int = 1000, + comm=None, +): + prob = DivingHeuristic(m) + res: pybnb.SolverResults = pybnb.solve( + prob, + queue_strategy=pybnb.QueueStrategy.bound, + objective_stop=prob.infeasible_objective(), + node_limit=node_limit, + time_limit=time_limit, + comm=comm, + ) + ss = pybnb.SolutionStatus + if res.solution_status in {ss.feasible, ss.optimal}: + best_obj = res.objective + best_sol: MutableMapping[_GeneralVarData, float] = ComponentMap( + zip(prob.all_vars, res.best_node.state[2]) + ) + else: + best_obj = None + best_sol = None + + restore_integers(prob.binary_vars, prob.integer_vars) + for v, lb, ub in zip(prob.bin_and_int_vars, prob.orig_lbs, prob.orig_ubs): + v.unfix() + v.setlb(lb) + v.setub(ub) + + if best_sol is not None: + # double check that the solution is feasible + for v, val in best_sol.items(): + v.set_value(val, skip_validation=True) + assert_feasible(m, prob.all_vars, feasibility_tol, integer_tol) + + return best_obj, best_sol diff --git a/pyomo/contrib/coramin/heuristics/feasibility_pump.py b/pyomo/contrib/coramin/heuristics/feasibility_pump.py new file mode 100644 index 00000000000..03455cc9e4c --- /dev/null +++ b/pyomo/contrib/coramin/heuristics/feasibility_pump.py @@ -0,0 +1,222 @@ +import pyomo.environ as pe +from pyomo.core.base.block import _BlockData +from pyomo.contrib.appsi.base import Solver +from pyomo.common.collections import ComponentSet +from pyomo.core.expr.visitor import identify_variables +from pyomo.contrib.coramin.utils.pyomo_utils import get_objective +from typing import List, Sequence, Tuple +from pyomo.core.base.var import _GeneralVarData +import math +import time +from pyomo.common.modeling import unique_component_name +import random +from pyomo.common.dependencies import numpy as np + + +def collect_integer_vars( + m: _BlockData, +) -> Tuple[List[_GeneralVarData], List[_GeneralVarData]]: + binary_vars = ComponentSet() + integer_vars = ComponentSet() + for c in m.component_data_objects( + pe.Constraint, active=True, descend_into=pe.Block + ): + for v in identify_variables(c.body, include_fixed=False): + if v.is_binary(): + binary_vars.add(v) + elif v.is_integer(): + integer_vars.add(v) + obj = get_objective(m) + if obj is not None: + for v in identify_variables(obj.expr, include_fixed=False): + if v.is_binary(): + binary_vars.add(v) + elif v.is_integer(): + integer_vars.add(v) + return list(binary_vars), list(integer_vars) + + +def relax_integers( + binary_vars: Sequence[_GeneralVarData], integer_vars: Sequence[_GeneralVarData] +): + for v in list(binary_vars) + list(integer_vars): + lb, ub = v.bounds + v.domain = pe.Reals + v.setlb(lb) + v.setub(ub) + + +def restore_integers( + binary_vars: Sequence[_GeneralVarData], integer_vars: Sequence[_GeneralVarData] +): + for v in binary_vars: + v.domain = pe.Binary + for v in integer_vars: + v.domain = pe.Integers + + +def check_feasible( + binary_vars: Sequence[_GeneralVarData], + integer_vars: Sequence[_GeneralVarData], + integer_tol=1e-4, +): + feas = True + for v in list(binary_vars) + list(integer_vars): + v_val = v.value + if not math.isclose(v_val, round(v_val), abs_tol=integer_tol, rel_tol=0): + print(v_val) + feas = False + break + return feas + + +def run_feasibility_pump( + m: _BlockData, + nlp_solver: Solver, + time_limit: float = math.inf, + iter_limit=300, + integer_tol=1e-4, + use_fixing: bool = False, + use_flip: bool = True, +): + t0 = time.time() + + binary_vars, integer_vars = collect_integer_vars(m) + relax_integers(binary_vars, integer_vars) + + nlp_solver.config.load_solution = False + + res = nlp_solver.solve(m) + if res.best_feasible_objective is None: + restore_integers(binary_vars, integer_vars) + return None + + res.solution_loader.load_vars(binary_vars) + res.solution_loader.load_vars(integer_vars) + is_feas = check_feasible(binary_vars, integer_vars, integer_tol) + if is_feas: + restore_integers(binary_vars, integer_vars) + res.load_vars() + return res + + orig_obj = get_objective(m) + orig_obj.deactivate() + + feasible_results = None + new_obj_name = unique_component_name(m, 'fp_obj') + last_target_binary_vals = None + last_target_integer_vals = None + n_bin = len(binary_vars) + for _iter in range(iter_limit): + if time.time() - t0 > time_limit: + break + + if hasattr(m, new_obj_name): + delattr(m, new_obj_name) + + target_binary_vals = [round(v.value) for v in binary_vars] + target_integer_vals = [round(v.value) for v in integer_vars] + + dist_list = list() + ndx = 0 + for v, val in zip(binary_vars, target_binary_vals): + dist_list.append((ndx, abs(v.value - val))) + ndx += 1 + for v, val in zip(integer_vars, target_integer_vals): + dist_list.append((ndx, abs(v.value - val))) + ndx += 1 + dist_list.sort(key=lambda i: i[1], reverse=True) + + if use_fixing: + ndx_to_fix = None + ndx = len(binary_vars) + len(integer_vars) - 1 + while ndx >= 0: + tmp = dist_list[ndx][0] + if tmp < n_bin: + if not binary_vars[tmp].is_fixed(): + ndx_to_fix = tmp + break + else: + if not integer_vars[tmp - n_bin].is_fixed(): + ndx_to_fix = tmp + break + ndx -= 1 + if ndx_to_fix < n_bin: + binary_vars[ndx_to_fix].fix(target_binary_vals[ndx_to_fix]) + else: + _ndx = ndx_to_fix - n_bin + integer_vars[_ndx].fix(target_integer_vals[_ndx]) + + if last_target_binary_vals is not None and use_flip: + if ( + target_binary_vals == last_target_binary_vals + and target_integer_vals == last_target_integer_vals + ): + print('flipping') + T = math.floor(0.5 * (len(binary_vars) + len(integer_vars))) + T = 10 + num_flip = random.randint(math.floor(0.5 * T), math.ceil(1.5 * T)) + dist_list = list() + ndx = 0 + for v, val in zip(binary_vars, target_binary_vals): + dist_list.append((ndx, abs(v.value - val))) + ndx += 1 + for v, val in zip(integer_vars, target_integer_vals): + dist_list.append((ndx, abs(v.value - val))) + ndx += 1 + dist_list.sort(key=lambda i: i[1], reverse=True) + indices_to_flip = [i[0] for i in dist_list[:num_flip]] + for ndx in indices_to_flip: + if ndx < n_bin: + if target_binary_vals[ndx] == 0: + target_binary_vals[ndx] = 1 + else: + assert target_binary_vals[ndx] == 1 + target_binary_vals[ndx] = 0 + else: + _ndx = ndx - n_bin + if target_integer_vals[_ndx] == 0: + target_integer_vals[_ndx] = 1 + else: + assert target_integer_vals[_ndx] == 1 + target_integer_vals[_ndx] = 0 + + last_target_binary_vals = target_binary_vals + last_target_integer_vals = target_integer_vals + + obj_expr = 0 + for v, val in zip(binary_vars, target_binary_vals): + if val == 0: + obj_expr += v + else: + assert val == 1 + obj_expr += 1 - v + for v, val in zip(integer_vars, target_integer_vals): + obj_expr += (v - val) ** 2 + setattr(m, new_obj_name, pe.Objective(expr=obj_expr)) + + res = nlp_solver.solve(m) + if res.best_feasible_objective is None: + print('failed') + break + res.solution_loader.load_vars([v for v in binary_vars if not v.is_fixed()]) + res.solution_loader.load_vars([v for v in integer_vars if not v.is_fixed()]) + + is_feas = check_feasible(binary_vars, integer_vars, integer_tol) + if is_feas: + feasible_results = res + break + + restore_integers(binary_vars, integer_vars) + orig_obj.activate() + if hasattr(m, new_obj_name): + delattr(m, new_obj_name) + if feasible_results is not None: + feasible_results.solution_loader.load_vars() + for v in binary_vars: + v.unfix() + for v in integer_vars: + v.unfix() + print(orig_obj.expr()) + + return feasible_results diff --git a/pyomo/contrib/coramin/heuristics/tests/__init__.py b/pyomo/contrib/coramin/heuristics/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/heuristics/tests/test_heuristics.py b/pyomo/contrib/coramin/heuristics/tests/test_heuristics.py new file mode 100644 index 00000000000..325d27de707 --- /dev/null +++ b/pyomo/contrib/coramin/heuristics/tests/test_heuristics.py @@ -0,0 +1,28 @@ +from pyomo.common import unittest +import pyomo.environ as pe +from pyomo.contrib.coramin.heuristics.diving import run_diving_heuristic + + +ipopt_available = pe.SolverFactory('ipopt').available() + + +@unittest.skipUnless(ipopt_available, 'ipopt is not available') +class TestDiving(unittest.TestCase): + def test_diving_1(self): + m = pe.ConcreteModel() + m.x = pe.Var() + m.y = pe.Var() + m.z1 = pe.Var(domain=pe.Binary) + m.z2 = pe.Var(domain=pe.Binary) + m.obj = pe.Objective(expr=m.x**2 + m.y**2) + m.c1 = pe.Constraint(expr=(m.y - pe.exp(m.x)) * m.z1 >= 0) + m.c2 = pe.Constraint(expr=(m.y - (m.x - 1) ** 2) * m.z1 >= 0) + m.c3 = pe.Constraint(expr=(m.y - m.x - 2) * m.z2 >= 0) + m.c4 = pe.Constraint(expr=(m.y + m.x - 2) * m.z2 >= 0) + m.c5 = pe.Constraint(expr=m.z1 + m.z2 == 1) + obj, sol = run_diving_heuristic(m) + self.assertAlmostEqual(obj, 1) + self.assertAlmostEqual(sol[m.x], 0) + self.assertAlmostEqual(sol[m.y], 1) + self.assertAlmostEqual(sol[m.z1], 1) + self.assertAlmostEqual(sol[m.z2], 0) diff --git a/pyomo/contrib/coramin/relaxations/__init__.py b/pyomo/contrib/coramin/relaxations/__init__.py new file mode 100644 index 00000000000..a4088614410 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/__init__.py @@ -0,0 +1,17 @@ +from .relaxations_base import ( + BaseRelaxation, + BaseRelaxationData, + BasePWRelaxation, + BasePWRelaxationData, +) +from .mccormick import PWMcCormickRelaxation, PWMcCormickRelaxationData +from .segments import compute_k_segment_points +from .univariate import PWXSquaredRelaxation, PWXSquaredRelaxationData +from .univariate import PWUnivariateRelaxation, PWUnivariateRelaxationData +from .univariate import PWArctanRelaxation, PWArctanRelaxationData +from .univariate import PWSinRelaxation, PWSinRelaxationData +from .univariate import PWCosRelaxation, PWCosRelaxationData +from .auto_relax import relax +from .alphabb import AlphaBBRelaxationData, AlphaBBRelaxation +from .multivariate import MultivariateRelaxationData, MultivariateRelaxation +from .iterators import relaxation_data_objects, nonrelaxation_component_data_objects diff --git a/pyomo/contrib/coramin/relaxations/_utils.py b/pyomo/contrib/coramin/relaxations/_utils.py new file mode 100644 index 00000000000..b1a4ba3b582 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/_utils.py @@ -0,0 +1,88 @@ +import logging +import pyomo.environ as pe +import warnings +import math + +logger = logging.getLogger(__name__) +pyo = pe + + +def _get_bnds_tuple(v): + lb = pe.value(v.lb) + ub = pe.value(v.ub) + if lb is None: + lb = -math.inf + if ub is None: + ub = math.inf + + return lb, ub + + +def _get_bnds_list(v): + return list(_get_bnds_tuple(v)) + + +def var_info_str(v): + s = '\tVar: {0}\n'.format(v) + return s + + +def bnds_info_str(vlb, vub): + s = '\tLB: {0}\n'.format(vlb) + s += '\tUB: {0}\n'.format(vub) + return s + + +def x_pts_info_str(_x_pts): + s = '\tx_pts: {0}\n'.format(_x_pts) + return s + + +def check_var_pts(x, x_pts=None): + xlb = pe.value(x.lb) + xub = pe.value(x.ub) + + if xlb is None: + xlb = -math.inf + if xub is None: + xub = math.inf + + raise_error = False + raise_warning = False + msg = None + + if xub < xlb: + msg = ( + 'Lower bound is larger than upper bound:\n' + + var_info_str(x) + + bnds_info_str(xlb, xub) + ) + raise_error = True + + if x_pts is not None: + ordered = all(x_pts[i] <= x_pts[i + 1] for i in range(len(x_pts) - 1)) + if not ordered: + msg = ( + 'x_pts must be ordered:\n' + + var_info_str(x) + + bnds_info_str(xlb, xub) + + x_pts_info_str(x_pts) + ) + raise_error = True + + if xlb != x_pts[0] or xub != x_pts[-1]: + msg = ( + 'end points of the x_pts list must be equal to the bounds on the x variable:\n' + + var_info_str(x) + + bnds_info_str(xlb, xub) + + x_pts_info_str(x_pts) + ) + raise_error = True + + if raise_error: + logger.error(msg) + raise ValueError(msg) + + if raise_warning: + logger.warning(msg) + warnings.warn(msg) diff --git a/pyomo/contrib/coramin/relaxations/alphabb.py b/pyomo/contrib/coramin/relaxations/alphabb.py new file mode 100644 index 00000000000..eba240dc3ed --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/alphabb.py @@ -0,0 +1,168 @@ +from pyomo.contrib.coramin.utils.coramin_enums import EigenValueBounder, RelaxationSide +from pyomo.contrib.coramin.relaxations.custom_block import declare_custom_block +from pyomo.contrib.coramin.relaxations.relaxations_base import BaseRelaxationData +from pyomo.contrib.coramin.relaxations.hessian import Hessian +from typing import Optional, Tuple +from pyomo.core.base.var import _GeneralVarData +from pyomo.core.expr.numeric_expr import ExpressionBase +from pyomo.core.expr.visitor import identify_variables +from pyomo.contrib import appsi +from pyomo.core.base.param import ScalarParam, IndexedParam +from pyomo.core.base.set import OrderedScalarSet + + +@declare_custom_block(name='AlphaBBRelaxation') +class AlphaBBRelaxationData(BaseRelaxationData): + def __init__(self, component): + super().__init__(component) + self._xs: Optional[Tuple[_GeneralVarData]] = None + self._aux_var = None + self._f_x_expr: Optional[ExpressionBase] = None + self._alphabb_rhs: Optional[ExpressionBase] = None + self._hessian: Optional[Hessian] = None + self._alpha: Optional[ScalarParam] = None + self._var_set: Optional[OrderedScalarSet] = None + self._lb_params: Optional[IndexedParam] = None + self._ub_params: Optional[IndexedParam] = None + + @property + def hessian(self): + return self._hessian + + def get_rhs_vars(self) -> Tuple[_GeneralVarData, ...]: + return self._xs + + def get_rhs_expr(self) -> ExpressionBase: + return self._f_x_expr + + def vars_with_bounds_in_relaxation(self): + if self.is_rhs_convex(): + return list() + else: + return list(self._xs) + + def is_rhs_convex(self): + return self._hessian.get_minimum_eigenvalue() >= 0 + + def is_rhs_concave(self): + return self._hessian.get_maximum_eigenvalue() <= 0 + + def has_convex_underestimator(self): + return self.relaxation_side == RelaxationSide.UNDER + + def has_concave_overestimator(self): + return self.relaxation_side == RelaxationSide.OVER + + def _get_expr_for_oa(self): + return self._alphabb_rhs + + def set_input( + self, + aux_var: _GeneralVarData, + f_x_expr: ExpressionBase, + relaxation_side: RelaxationSide, + use_linear_relaxation: bool = True, + large_coef: float = 1e5, + small_coef: float = 1e-10, + safety_tol: float = 1e-10, + eigenvalue_bounder: EigenValueBounder = EigenValueBounder.LinearProgram, + eigenvalue_opt: Optional[appsi.base.Solver] = None, + hessian: Optional[Hessian] = None, + ): + del self._alpha, self._alphabb_rhs, self._var_set, self._lb_params + del self._ub_params + self._alpha, self._alphabb_rhs, self._var_set = None, None, None + self._lb_params, self._ub_params = None, None + self._relaxation_side = relaxation_side + super().set_input( + relaxation_side=relaxation_side, + use_linear_relaxation=use_linear_relaxation, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + ) + self._xs = tuple(identify_variables(f_x_expr, include_fixed=False)) + object.__setattr__(self, '_aux_var', aux_var) + self._f_x_expr = f_x_expr + if hessian is None: + hessian = Hessian( + expr=f_x_expr, opt=eigenvalue_opt, method=eigenvalue_bounder + ) + self._hessian = hessian + + def build( + self, + aux_var: _GeneralVarData, + f_x_expr: ExpressionBase, + relaxation_side: RelaxationSide, + use_linear_relaxation: bool = True, + large_coef: float = 1e5, + small_coef: float = 1e-10, + safety_tol: float = 1e-10, + eigenvalue_bounder: EigenValueBounder = EigenValueBounder.LinearProgram, + eigenvalue_opt: appsi.base.Solver = None, + ): + self.set_input( + aux_var=aux_var, + f_x_expr=f_x_expr, + relaxation_side=relaxation_side, + use_linear_relaxation=use_linear_relaxation, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + eigenvalue_bounder=eigenvalue_bounder, + eigenvalue_opt=eigenvalue_opt, + ) + self.rebuild() + + @property + def use_linear_relaxation(self): + return self._use_linear_relaxation + + @use_linear_relaxation.setter + def use_linear_relaxation(self, value): + self._use_linear_relaxation = value + + @property + def relaxation_side(self): + return BaseRelaxationData.relaxation_side.fget(self) + + @relaxation_side.setter + def relaxation_side(self, val): + if val != self.relaxation_side: + raise ValueError( + 'Cannot change the relaxation side of an AlphaBBRelaxation' + ) + if val == RelaxationSide.BOTH: + raise ValueError( + 'AlphaBBRelaxation only supports relaxation sides of UNDER or OVER, not BOTH.' + ) + + def rebuild(self, build_nonlinear_constraint=False, ensure_oa_at_vertices=True): + if self.relaxation_side == RelaxationSide.UNDER: + alpha = max(0, -0.5 * self._hessian.get_minimum_eigenvalue()) + else: + alpha = max(0, 0.5 * self._hessian.get_maximum_eigenvalue()) + alpha = -alpha + if self._alpha is None: + del self._alpha, self._alphabb_rhs, self._var_set + del self._lb_params, self._ub_params + self._alpha = ScalarParam(mutable=True) + self._var_set = OrderedScalarSet(initialize=list(range(len(self._xs)))) + self._lb_params = IndexedParam(self._var_set, mutable=True) + self._ub_params = IndexedParam(self._var_set, mutable=True) + alpha_sum = 0 + for ndx, v in enumerate(self._xs): + p_lb = self._lb_params[ndx] + p_ub = self._ub_params[ndx] + alpha_sum += (v - p_lb) * (v - p_ub) + self._alphabb_rhs = self.get_rhs_expr() + self._alpha * alpha_sum + self._alpha.value = alpha + for ndx, v in enumerate(self._xs): + self._lb_params[ndx].value = v.lb + self._ub_params[ndx].value = v.ub + + super().rebuild( + build_nonlinear_constraint=build_nonlinear_constraint, + ensure_oa_at_vertices=ensure_oa_at_vertices, + ) diff --git a/pyomo/contrib/coramin/relaxations/auto_relax.py b/pyomo/contrib/coramin/relaxations/auto_relax.py new file mode 100644 index 00000000000..90c713561b3 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/auto_relax.py @@ -0,0 +1,1332 @@ +import pyomo.environ as pe +from pyomo.common.collections import ComponentMap +import pyomo.core.expr.numeric_expr as numeric_expr +from pyomo.core.expr.visitor import ExpressionValueVisitor +from pyomo.core.expr.numvalue import ( + nonpyomo_leaf_types, + NumericValue, + is_fixed, + is_constant, +) +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr, fbbt +import math +import logging +from .univariate import ( + PWUnivariateRelaxation, + PWXSquaredRelaxation, + PWCosRelaxation, + PWSinRelaxation, + PWArctanRelaxation, +) +from .mccormick import PWMcCormickRelaxation +from pyomo.contrib.coramin.utils.coramin_enums import RelaxationSide, FunctionShape +from pyomo.core.base.expression import _GeneralExpressionData, SimpleExpression +from pyomo.repn.standard_repn import generate_standard_repn +from .iterators import relaxation_data_objects +from pyomo.contrib.coramin.clone import clone_shallow_active_flat + + +logger = logging.getLogger(__name__) + + +class Hashable: + def __init__(self, *args): + entries = list() + for i in args: + itype = type(i) + if itype is tuple or itype in nonpyomo_leaf_types: + entries.append(i) + elif isinstance(i, NumericValue): + entries.append(id(i)) + else: + raise NotImplementedError(f'unexpected entry: {str(i)}') + self.entries = entries + self.hashable_entries = tuple(entries) + + def __eq__(self, other): + if isinstance(other, Hashable): + return self.entries == other.entries + return False + + def __hash__(self): + return hash(self.hashable_entries) + + +class RelaxationException(Exception): + pass + + +class RelaxationCounter(object): + def __init__(self): + self.count = 0 + + def increment(self): + self.count += 1 + + def __str__(self): + return str(self.count) + + +def compute_float_bounds_on_expr(expr): + lb, ub = compute_bounds_on_expr(expr) + if lb is None: + lb = -math.inf + if ub is None: + ub = math.inf + + return lb, ub + + +def replace_sub_expression_with_aux_var(arg, parent_block): + if type(arg) in nonpyomo_leaf_types: + return arg + elif arg.is_expression_type(): + _var = parent_block.aux_vars.add() + parent_block.vars.append(_var) + _con = parent_block.linear.cons.add(_var == arg) + fbbt(_con) + return _var + else: + return arg + + +def _get_aux_var(parent_block, expr): + _aux_var = parent_block.aux_vars.add() + parent_block.vars.append(_aux_var) + lb, ub = compute_bounds_on_expr(expr) + _aux_var.setlb(lb) + _aux_var.setub(ub) + try: + expr_value = pe.value(expr, exception=False) + except ArithmeticError: + expr_value = None + if expr_value is not None and pe.value(_aux_var, exception=False) is None: + _aux_var.set_value(expr_value, skip_validation=True) + return _aux_var + + +def _relax_leaf_to_root_ProductExpression( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg1, arg2 = values + + # The purpose of the next bit of code is to find common quadratic terms. For example, suppose we are relaxing + # a model with the following two constraints: + # + # w1 - x*y = 0 + # w2 + 3*x*y = 0 + # + # we want to end up with + # + # w1 - aux1 = 0 + # w2 + 3*aux1 = 0 + # aux1 = x*y + # + # rather than + # + # w1 - aux1 = 0 + # w2 + 3*aux2 = 0 + # aux1 = x*y + # aux2 = x*y + # + + h1 = Hashable(arg1, arg2, 'mul') + h2 = Hashable(arg2, arg1, 'mul') + if h1 in aux_var_map or h2 in aux_var_map: + if h1 in aux_var_map: + _aux_var, relaxation = aux_var_map[h1] + else: + _aux_var, relaxation = aux_var_map[h2] + relaxation_side = relaxation_side_map[node] + if relaxation_side != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + res = _aux_var + degree_map[res] = 1 + else: + degree_1 = degree_map[arg1] + degree_2 = degree_map[arg2] + if degree_1 == 0: + res = arg1 * arg2 + degree_map[res] = degree_2 + elif degree_2 == 0: + res = arg2 * arg1 + degree_map[res] = degree_1 + elif ( + arg1.__class__ == numeric_expr.MonomialTermExpression + or arg2.__class__ == numeric_expr.MonomialTermExpression + ): + if arg1.__class__ == numeric_expr.MonomialTermExpression: + coef1, arg1 = arg1.args + else: + coef1 = 1 + if arg2.__class__ == numeric_expr.MonomialTermExpression: + coef2, arg2 = arg2.args + else: + coef2 = 1 + coef = coef1 * coef2 + _new_relaxation_side_map = ComponentMap() + _reformulated = coef * (arg1 * arg2) + _new_relaxation_side_map[_reformulated] = relaxation_side_map[node] + res = _relax_expr( + expr=_reformulated, + aux_var_map=aux_var_map, + parent_block=parent_block, + relaxation_side_map=_new_relaxation_side_map, + counter=counter, + degree_map=degree_map, + ) + degree_map[res] = 1 + elif arg1 is arg2: + # reformulate arg1 * arg2 as arg1**2 + _new_relaxation_side_map = ComponentMap() + _reformulated = arg1**2 + _new_relaxation_side_map[_reformulated] = relaxation_side_map[node] + res = _relax_expr( + expr=_reformulated, + aux_var_map=aux_var_map, + parent_block=parent_block, + relaxation_side_map=_new_relaxation_side_map, + counter=counter, + degree_map=degree_map, + ) + degree_map[res] = 1 + else: + _aux_var = _get_aux_var(parent_block, arg1 * arg2) + arg1 = replace_sub_expression_with_aux_var(arg1, parent_block) + arg2 = replace_sub_expression_with_aux_var(arg2, parent_block) + relaxation_side = relaxation_side_map[node] + relaxation = PWMcCormickRelaxation() + relaxation.set_input( + x1=arg1, x2=arg2, aux_var=_aux_var, relaxation_side=relaxation_side + ) + aux_var_map[h1] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + res = _aux_var + degree_map[res] = 1 + return res + + +def _relax_leaf_to_root_DivisionExpression( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg1, arg2 = values + h1 = Hashable(arg1, arg2, 'div') + if arg1.__class__ == numeric_expr.MonomialTermExpression: + coef1, arg1 = arg1.args + else: + coef1 = 1 + if arg2.__class__ == numeric_expr.MonomialTermExpression: + coef2, arg2 = arg2.args + else: + coef2 = 1 + coef = coef1 / coef2 + degree_1 = degree_map[arg1] + degree_2 = degree_map[arg2] + + if degree_2 == 0: + res = (coef / arg2) * arg1 + degree_map[res] = degree_1 + return res + elif h1 in aux_var_map: + _aux_var, relaxation = aux_var_map[h1] + relaxation_side = relaxation_side_map[node] + if relaxation_side != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + res = coef * _aux_var + degree_map[_aux_var] = 1 + degree_map[res] = 1 + return res + elif degree_1 == 0: + h2 = Hashable(arg2, 'reciprocal') + if h2 in aux_var_map: + _aux_var, relaxation = aux_var_map[h2] + relaxation_side = relaxation_side_map[node] + if relaxation_side != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + res = coef * arg1 * _aux_var + degree_map[_aux_var] = 1 + degree_map[res] = 1 + return res + else: + _aux_var = _get_aux_var(parent_block, 1 / arg2) + arg2 = replace_sub_expression_with_aux_var(arg2, parent_block) + relaxation_side = relaxation_side_map[node] + degree_map[_aux_var] = 1 + if compute_float_bounds_on_expr(arg2)[0] > 0: + relaxation = PWUnivariateRelaxation() + relaxation.set_input( + x=arg2, + aux_var=_aux_var, + relaxation_side=relaxation_side, + f_x_expr=1 / arg2, + shape=FunctionShape.CONVEX, + ) + elif compute_float_bounds_on_expr(arg2)[1] < 0: + relaxation = PWUnivariateRelaxation() + relaxation.set_input( + x=arg2, + aux_var=_aux_var, + relaxation_side=relaxation_side, + f_x_expr=1 / arg2, + shape=FunctionShape.CONCAVE, + ) + else: + _one = parent_block.aux_vars.add() + _one.fix(1) + relaxation = PWMcCormickRelaxation() + relaxation.set_input( + x1=arg2, x2=_aux_var, aux_var=_one, relaxation_side=relaxation_side + ) + aux_var_map[h2] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + res = coef * arg1 * _aux_var + degree_map[res] = 1 + return res + else: + _aux_var = _get_aux_var(parent_block, arg1 / arg2) + arg1 = replace_sub_expression_with_aux_var(arg1, parent_block) + arg2 = replace_sub_expression_with_aux_var(arg2, parent_block) + relaxation_side = relaxation_side_map[node] + arg2_lb, arg2_ub = compute_float_bounds_on_expr(arg2) + if arg2_lb >= 0: + if relaxation_side == RelaxationSide.UNDER: + relaxation_side = RelaxationSide.OVER + elif relaxation_side == RelaxationSide.OVER: + relaxation_side = RelaxationSide.UNDER + else: + assert relaxation_side == RelaxationSide.BOTH + elif arg2_ub <= 0: + pass + else: + relaxation_side = RelaxationSide.BOTH + relaxation = PWMcCormickRelaxation() + relaxation.set_input( + x1=arg2, x2=_aux_var, aux_var=arg1, relaxation_side=relaxation_side + ) + aux_var_map[h1] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + res = coef * _aux_var + degree_map[_aux_var] = 1 + degree_map[res] = 1 + return res + + +def _relax_quadratic( + arg1, aux_var_map, relaxation_side, degree_map, parent_block, counter +): + _aux_var = _get_aux_var(parent_block, arg1**2) + arg1 = replace_sub_expression_with_aux_var(arg1, parent_block) + degree_map[_aux_var] = 1 + relaxation = PWXSquaredRelaxation() + relaxation.set_input(x=arg1, aux_var=_aux_var, relaxation_side=relaxation_side) + aux_var_map[Hashable(arg1, 2, 'pow')] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + return _aux_var + + +def _relax_convex_pow( + arg1, + arg2, + aux_var_map, + relaxation_side, + degree_map, + parent_block, + counter, + swap=False, +): + _aux_var = _get_aux_var(parent_block, arg1**arg2) + if swap: + arg2 = replace_sub_expression_with_aux_var(arg2, parent_block) + _x = arg2 + else: + arg1 = replace_sub_expression_with_aux_var(arg1, parent_block) + _x = arg1 + assert type(arg2) in {int, float} + if round(arg2) != arg2: + if arg1.lb is None or arg1.lb < 0: + arg1.setlb(0) + degree_map[_aux_var] = 1 + relaxation = PWUnivariateRelaxation() + relaxation.set_input( + x=_x, + aux_var=_aux_var, + relaxation_side=relaxation_side, + f_x_expr=arg1**arg2, + shape=FunctionShape.CONVEX, + ) + aux_var_map[Hashable(arg1, arg2, 'pow')] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + return _aux_var + + +def _relax_concave_pow( + arg1, arg2, aux_var_map, relaxation_side, degree_map, parent_block, counter +): + _aux_var = _get_aux_var(parent_block, arg1**arg2) + arg1 = replace_sub_expression_with_aux_var(arg1, parent_block) + degree_map[_aux_var] = 1 + relaxation = PWUnivariateRelaxation() + relaxation.set_input( + x=arg1, + aux_var=_aux_var, + relaxation_side=relaxation_side, + f_x_expr=arg1**arg2, + shape=FunctionShape.CONCAVE, + ) + aux_var_map[Hashable(arg1, arg2, 'pow')] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + return _aux_var + + +def _relax_leaf_to_root_PowExpression( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg1, arg2 = values + h = Hashable(arg1, arg2, 'pow') + if h in aux_var_map: + _aux_var, relaxation = aux_var_map[h] + if relaxation_side_map[node] != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + degree_map[_aux_var] = 1 + return _aux_var + else: + degree1 = degree_map[arg1] + degree2 = degree_map[arg2] + if degree2 == 0: + if degree1 == 0: + res = arg1**arg2 + degree_map[res] = 0 + return res + if not is_constant(arg2): + logger.debug( + 'Only constant exponents are supported: ' + + str(arg1**arg2) + + '\nReplacing ' + + str(arg2) + + ' with its value.' + ) + arg2 = pe.value(arg2) + if arg2 == 1: + return arg1 + elif arg2 == 0: + res = 1 + degree_map[res] = 0 + return res + elif arg2 == 2: + return _relax_quadratic( + arg1=arg1, + aux_var_map=aux_var_map, + relaxation_side=relaxation_side_map[node], + degree_map=degree_map, + parent_block=parent_block, + counter=counter, + ) + elif arg2 >= 0: + if arg2 == round(arg2): + if arg2 % 2 == 0 or compute_float_bounds_on_expr(arg1)[0] >= 0: + return _relax_convex_pow( + arg1=arg1, + arg2=arg2, + aux_var_map=aux_var_map, + relaxation_side=relaxation_side_map[node], + degree_map=degree_map, + parent_block=parent_block, + counter=counter, + ) + elif compute_float_bounds_on_expr(arg1)[1] <= 0: + return _relax_concave_pow( + arg1=arg1, + arg2=arg2, + aux_var_map=aux_var_map, + relaxation_side=relaxation_side_map[node], + degree_map=degree_map, + parent_block=parent_block, + counter=counter, + ) + else: # reformulate arg1 ** arg2 as arg1 * arg1 ** (arg2 - 1) + _new_relaxation_side_map = ComponentMap() + _reformulated = arg1 * arg1 ** (arg2 - 1) + _new_relaxation_side_map[_reformulated] = relaxation_side_map[ + node + ] + res = _relax_expr( + expr=_reformulated, + aux_var_map=aux_var_map, + parent_block=parent_block, + relaxation_side_map=_new_relaxation_side_map, + counter=counter, + degree_map=degree_map, + ) + degree_map[res] = 1 + return res + else: + if arg2 < 1: + return _relax_concave_pow( + arg1=arg1, + arg2=arg2, + aux_var_map=aux_var_map, + relaxation_side=relaxation_side_map[node], + degree_map=degree_map, + parent_block=parent_block, + counter=counter, + ) + else: + return _relax_convex_pow( + arg1=arg1, + arg2=arg2, + aux_var_map=aux_var_map, + relaxation_side=relaxation_side_map[node], + degree_map=degree_map, + parent_block=parent_block, + counter=counter, + ) + else: + if arg2 == round(arg2): + if compute_float_bounds_on_expr(arg1)[0] >= 0: + return _relax_convex_pow( + arg1=arg1, + arg2=arg2, + aux_var_map=aux_var_map, + relaxation_side=relaxation_side_map[node], + degree_map=degree_map, + parent_block=parent_block, + counter=counter, + ) + elif compute_float_bounds_on_expr(arg1)[1] <= 0: + if arg2 % 2 == 0: + return _relax_convex_pow( + arg1=arg1, + arg2=arg2, + aux_var_map=aux_var_map, + relaxation_side=relaxation_side_map[node], + degree_map=degree_map, + parent_block=parent_block, + counter=counter, + ) + else: + return _relax_concave_pow( + arg1=arg1, + arg2=arg2, + aux_var_map=aux_var_map, + relaxation_side=relaxation_side_map[node], + degree_map=degree_map, + parent_block=parent_block, + counter=counter, + ) + else: + # reformulate arg1 ** arg2 as 1 / arg1 ** (-arg2) + _new_relaxation_side_map = ComponentMap() + _reformulated = 1 / (arg1 ** (-arg2)) + _new_relaxation_side_map[_reformulated] = relaxation_side_map[ + node + ] + res = _relax_expr( + expr=_reformulated, + aux_var_map=aux_var_map, + parent_block=parent_block, + relaxation_side_map=_new_relaxation_side_map, + counter=counter, + degree_map=degree_map, + ) + degree_map[res] = 1 + return res + else: + return _relax_convex_pow( + arg1=arg1, + arg2=arg2, + aux_var_map=aux_var_map, + relaxation_side=relaxation_side_map[node], + degree_map=degree_map, + parent_block=parent_block, + counter=counter, + ) + elif degree1 == 0: + if not is_constant(arg1): + logger.debug( + 'Found {0} raised to a variable power. However, {0} does not appear to be constant (maybe ' + 'it is or depends on a mutable Param?). Replacing {0} with its value.'.format( + str(arg1) + ) + ) + arg1 = pe.value(arg1) + if arg1 < 0: + raise ValueError( + 'Cannot raise a negative base to a variable exponent: ' + + str(arg1**arg2) + ) + return _relax_convex_pow( + arg1=arg1, + arg2=arg2, + aux_var_map=aux_var_map, + relaxation_side=relaxation_side_map[node], + degree_map=degree_map, + parent_block=parent_block, + counter=counter, + swap=True, + ) + else: + assert compute_float_bounds_on_expr(arg1)[0] >= 0 + _new_relaxation_side_map = ComponentMap() + _reformulated = pe.exp(arg2 * pe.log(arg1)) + _new_relaxation_side_map[_reformulated] = relaxation_side_map[node] + res = _relax_expr( + expr=_reformulated, + aux_var_map=aux_var_map, + parent_block=parent_block, + relaxation_side_map=_new_relaxation_side_map, + counter=counter, + degree_map=degree_map, + ) + degree_map[res] = 1 + return res + + +def _relax_leaf_to_root_SumExpression( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + res = sum(values) + degree_map[res] = max([degree_map[arg] for arg in values]) + return res + + +def _relax_leaf_to_root_NegationExpression( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg = values[0] + res = -arg + degree_map[res] = degree_map[arg] + return res + + +def _relax_leaf_to_root_sqrt( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg = values[0] + _new_relaxation_side_map = ComponentMap() + _reformulated = arg**0.5 + _new_relaxation_side_map[_reformulated] = relaxation_side_map[node] + res = _relax_expr( + expr=_reformulated, + aux_var_map=aux_var_map, + parent_block=parent_block, + relaxation_side_map=_new_relaxation_side_map, + counter=counter, + degree_map=degree_map, + ) + degree_map[res] = 1 + return res + + +def _relax_leaf_to_root_exp( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg = values[0] + degree = degree_map[arg] + if degree == 0: + res = pe.exp(arg) + degree_map[res] = 0 + return res + elif (id(arg), 'exp') in aux_var_map: + _aux_var, relaxation = aux_var_map[id(arg), 'exp'] + relaxation_side = relaxation_side_map[node] + if relaxation_side != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + degree_map[_aux_var] = 1 + return _aux_var + else: + _aux_var = _get_aux_var(parent_block, pe.exp(arg)) + arg = replace_sub_expression_with_aux_var(arg, parent_block) + relaxation_side = relaxation_side_map[node] + degree_map[_aux_var] = 1 + relaxation = PWUnivariateRelaxation() + relaxation.set_input( + x=arg, + aux_var=_aux_var, + relaxation_side=relaxation_side, + f_x_expr=pe.exp(arg), + shape=FunctionShape.CONVEX, + ) + aux_var_map[id(arg), 'exp'] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + return _aux_var + + +def _relax_leaf_to_root_log( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg = values[0] + degree = degree_map[arg] + if degree == 0: + res = pe.exp(arg) + degree_map[res] = 0 + return res + elif (id(arg), 'log') in aux_var_map: + _aux_var, relaxation = aux_var_map[id(arg), 'log'] + relaxation_side = relaxation_side_map[node] + if relaxation_side != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + degree_map[_aux_var] = 1 + return _aux_var + else: + _aux_var = _get_aux_var(parent_block, pe.log(arg)) + arg = replace_sub_expression_with_aux_var(arg, parent_block) + relaxation_side = relaxation_side_map[node] + degree_map[_aux_var] = 1 + relaxation = PWUnivariateRelaxation() + relaxation.set_input( + x=arg, + aux_var=_aux_var, + relaxation_side=relaxation_side, + f_x_expr=pe.log(arg), + shape=FunctionShape.CONCAVE, + ) + aux_var_map[id(arg), 'log'] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + return _aux_var + + +def _relax_leaf_to_root_log10( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg = values[0] + degree = degree_map[arg] + if degree == 0: + res = pe.exp(arg) + degree_map[res] = 0 + return res + elif (id(arg), 'log10') in aux_var_map: + _aux_var, relaxation = aux_var_map[id(arg), 'log10'] + relaxation_side = relaxation_side_map[node] + if relaxation_side != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + degree_map[_aux_var] = 1 + return _aux_var + else: + _aux_var = _get_aux_var(parent_block, pe.log10(arg)) + arg = replace_sub_expression_with_aux_var(arg, parent_block) + relaxation_side = relaxation_side_map[node] + degree_map[_aux_var] = 1 + relaxation = PWUnivariateRelaxation() + relaxation.set_input( + x=arg, + aux_var=_aux_var, + relaxation_side=relaxation_side, + f_x_expr=pe.log10(arg), + shape=FunctionShape.CONCAVE, + ) + aux_var_map[id(arg), 'log10'] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + return _aux_var + + +def _relax_leaf_to_root_sin( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg = values[0] + degree = degree_map[arg] + if degree == 0: + res = pe.sin(arg) + degree_map[res] = 0 + return res + elif (id(arg), 'sin') in aux_var_map: + _aux_var, relaxation = aux_var_map[id(arg), 'sin'] + relaxation_side = relaxation_side_map[node] + if relaxation_side != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + degree_map[_aux_var] = 1 + return _aux_var + else: + _aux_var = _get_aux_var(parent_block, pe.sin(arg)) + arg = replace_sub_expression_with_aux_var(arg, parent_block) + relaxation_side = relaxation_side_map[node] + degree_map[_aux_var] = 1 + relaxation = PWSinRelaxation() + relaxation.set_input(x=arg, aux_var=_aux_var, relaxation_side=relaxation_side) + aux_var_map[id(arg), 'sin'] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + return _aux_var + + +def _relax_leaf_to_root_cos( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg = values[0] + degree = degree_map[arg] + if degree == 0: + res = pe.cos(arg) + degree_map[res] = 0 + return res + elif (id(arg), 'cos') in aux_var_map: + _aux_var, relaxation = aux_var_map[id(arg), 'cos'] + relaxation_side = relaxation_side_map[node] + if relaxation_side != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + degree_map[_aux_var] = 1 + return _aux_var + else: + _aux_var = _get_aux_var(parent_block, pe.cos(arg)) + arg = replace_sub_expression_with_aux_var(arg, parent_block) + relaxation_side = relaxation_side_map[node] + degree_map[_aux_var] = 1 + relaxation = PWCosRelaxation() + relaxation.set_input(x=arg, aux_var=_aux_var, relaxation_side=relaxation_side) + aux_var_map[id(arg), 'cos'] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + return _aux_var + + +def _relax_leaf_to_root_arctan( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg = values[0] + degree = degree_map[arg] + if degree == 0: + res = pe.atan(arg) + degree_map[res] = 0 + return res + elif (id(arg), 'arctan') in aux_var_map: + _aux_var, relaxation = aux_var_map[id(arg), 'arctan'] + relaxation_side = relaxation_side_map[node] + if relaxation_side != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + degree_map[_aux_var] = 1 + return _aux_var + else: + _aux_var = _get_aux_var(parent_block, pe.atan(arg)) + arg = replace_sub_expression_with_aux_var(arg, parent_block) + relaxation_side = relaxation_side_map[node] + degree_map[_aux_var] = 1 + relaxation = PWArctanRelaxation() + relaxation.set_input(x=arg, aux_var=_aux_var, relaxation_side=relaxation_side) + aux_var_map[id(arg), 'arctan'] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + return _aux_var + + +def _relax_leaf_to_root_tan( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg = values[0] + degree = degree_map[arg] + if degree == 0: + res = pe.tan(arg) + degree_map[res] = 0 + return res + elif (id(arg), 'tan') in aux_var_map: + _aux_var, relaxation = aux_var_map[id(arg), 'tan'] + relaxation_side = relaxation_side_map[node] + if relaxation_side != relaxation.relaxation_side: + relaxation.relaxation_side = RelaxationSide.BOTH + degree_map[_aux_var] = 1 + return _aux_var + else: + _aux_var = _get_aux_var(parent_block, pe.tan(arg)) + arg = replace_sub_expression_with_aux_var(arg, parent_block) + relaxation_side = relaxation_side_map[node] + degree_map[_aux_var] = 1 + + if arg.lb >= 0 and arg.ub <= math.pi / 2: + relaxation = PWUnivariateRelaxation() + relaxation.set_input( + x=arg, + aux_var=_aux_var, + shape=FunctionShape.CONVEX, + f_x_expr=pe.tan(arg), + relaxation_side=relaxation_side, + ) + elif arg.lb >= -math.pi / 2 and arg.ub <= 0: + relaxation = PWUnivariateRelaxation() + relaxation.set_input( + x=arg, + aux_var=_aux_var, + shape=FunctionShape.CONCAVE, + f_x_expr=pe.tan(arg), + relaxation_side=relaxation_side, + ) + else: + raise NotImplementedError('Use alpha-BB here') + aux_var_map[id(arg), 'tan'] = (_aux_var, relaxation) + setattr(parent_block.relaxations, 'rel' + str(counter), relaxation) + counter.increment() + return _aux_var + + +_unary_leaf_to_root_map = dict() +_unary_leaf_to_root_map['exp'] = _relax_leaf_to_root_exp +_unary_leaf_to_root_map['log'] = _relax_leaf_to_root_log +_unary_leaf_to_root_map['log10'] = _relax_leaf_to_root_log10 +_unary_leaf_to_root_map['sin'] = _relax_leaf_to_root_sin +_unary_leaf_to_root_map['cos'] = _relax_leaf_to_root_cos +_unary_leaf_to_root_map['atan'] = _relax_leaf_to_root_arctan +_unary_leaf_to_root_map['sqrt'] = _relax_leaf_to_root_sqrt +_unary_leaf_to_root_map['tan'] = _relax_leaf_to_root_tan + + +def _relax_leaf_to_root_UnaryFunctionExpression( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + if node.getname() in _unary_leaf_to_root_map: + return _unary_leaf_to_root_map[node.getname()]( + node=node, + values=values, + aux_var_map=aux_var_map, + degree_map=degree_map, + parent_block=parent_block, + relaxation_side_map=relaxation_side_map, + counter=counter, + ) + else: + raise NotImplementedError('Cannot automatically relax ' + str(node)) + + +def _relax_leaf_to_root_GeneralExpression( + node, values, aux_var_map, degree_map, parent_block, relaxation_side_map, counter +): + arg = values[0] + return arg + + +_relax_leaf_to_root_map = dict() +_relax_leaf_to_root_map[numeric_expr.ProductExpression] = ( + _relax_leaf_to_root_ProductExpression +) +_relax_leaf_to_root_map[numeric_expr.SumExpression] = _relax_leaf_to_root_SumExpression +_relax_leaf_to_root_map[numeric_expr.LinearExpression] = ( + _relax_leaf_to_root_SumExpression +) +_relax_leaf_to_root_map[numeric_expr.MonomialTermExpression] = ( + _relax_leaf_to_root_ProductExpression +) +_relax_leaf_to_root_map[numeric_expr.NegationExpression] = ( + _relax_leaf_to_root_NegationExpression +) +_relax_leaf_to_root_map[numeric_expr.PowExpression] = _relax_leaf_to_root_PowExpression +_relax_leaf_to_root_map[numeric_expr.DivisionExpression] = ( + _relax_leaf_to_root_DivisionExpression +) +_relax_leaf_to_root_map[numeric_expr.UnaryFunctionExpression] = ( + _relax_leaf_to_root_UnaryFunctionExpression +) +_relax_leaf_to_root_map[numeric_expr.NPV_ProductExpression] = ( + _relax_leaf_to_root_ProductExpression +) +_relax_leaf_to_root_map[numeric_expr.NPV_SumExpression] = ( + _relax_leaf_to_root_SumExpression +) +_relax_leaf_to_root_map[numeric_expr.NPV_NegationExpression] = ( + _relax_leaf_to_root_NegationExpression +) +_relax_leaf_to_root_map[numeric_expr.NPV_PowExpression] = ( + _relax_leaf_to_root_PowExpression +) +_relax_leaf_to_root_map[numeric_expr.NPV_DivisionExpression] = ( + _relax_leaf_to_root_DivisionExpression +) +_relax_leaf_to_root_map[numeric_expr.NPV_UnaryFunctionExpression] = ( + _relax_leaf_to_root_UnaryFunctionExpression +) +_relax_leaf_to_root_map[_GeneralExpressionData] = _relax_leaf_to_root_GeneralExpression +_relax_leaf_to_root_map[SimpleExpression] = _relax_leaf_to_root_GeneralExpression + + +def _relax_root_to_leaf_ProductExpression(node, relaxation_side_map): + arg1, arg2 = node.args + if is_fixed(arg1): + relaxation_side_map[arg1] = RelaxationSide.BOTH + if isinstance(arg1, numeric_expr.ProductExpression): # see Pyomo issue #1147 + arg1_arg1 = arg1.args[0] + arg1_arg2 = arg1.args[1] + try: + arg1_arg1_val = pe.value(arg1_arg1) + except ValueError: + arg1_arg1_val = None + try: + arg1_arg2_val = pe.value(arg1_arg2) + except ValueError: + arg1_arg2_val = None + if arg1_arg1_val == 0 or arg1_arg2_val == 0: + arg1_val = 0 + else: + arg1_val = pe.value(arg1) + else: + arg1_val = pe.value(arg1) + if arg1_val >= 0: + relaxation_side_map[arg2] = relaxation_side_map[node] + else: + if relaxation_side_map[node] == RelaxationSide.UNDER: + relaxation_side_map[arg2] = RelaxationSide.OVER + elif relaxation_side_map[node] == RelaxationSide.OVER: + relaxation_side_map[arg2] = RelaxationSide.UNDER + else: + relaxation_side_map[arg2] = RelaxationSide.BOTH + elif is_fixed(arg2): + relaxation_side_map[arg2] = RelaxationSide.BOTH + if isinstance(arg2, numeric_expr.ProductExpression): # see Pyomo issue #1147 + arg2_arg1 = arg2.args[0] + arg2_arg2 = arg2.args[1] + try: + arg2_arg1_val = pe.value(arg2_arg1) + except ValueError: + arg2_arg1_val = None + try: + arg2_arg2_val = pe.value(arg2_arg2) + except ValueError: + arg2_arg2_val = None + if arg2_arg1_val == 0 or arg2_arg2_val == 0: + arg2_val = 0 + else: + arg2_val = pe.value(arg2) + else: + arg2_val = pe.value(arg2) + if arg2_val >= 0: + relaxation_side_map[arg1] = relaxation_side_map[node] + else: + if relaxation_side_map[node] == RelaxationSide.UNDER: + relaxation_side_map[arg1] = RelaxationSide.OVER + elif relaxation_side_map[node] == RelaxationSide.OVER: + relaxation_side_map[arg1] = RelaxationSide.UNDER + else: + relaxation_side_map[arg1] = RelaxationSide.BOTH + else: + relaxation_side_map[arg1] = RelaxationSide.BOTH + relaxation_side_map[arg2] = RelaxationSide.BOTH + + +def _relax_root_to_leaf_DivisionExpression(node, relaxation_side_map): + arg1, arg2 = node.args + relaxation_side_map[arg1] = RelaxationSide.BOTH + relaxation_side_map[arg2] = RelaxationSide.BOTH + + +def _relax_root_to_leaf_SumExpression(node, relaxation_side_map): + relaxation_side = relaxation_side_map[node] + + for arg in node.args: + relaxation_side_map[arg] = relaxation_side + + +def _relax_root_to_leaf_NegationExpression(node, relaxation_side_map): + arg = node.args[0] + relaxation_side = relaxation_side_map[node] + if relaxation_side == RelaxationSide.BOTH: + relaxation_side_map[arg] = RelaxationSide.BOTH + elif relaxation_side == RelaxationSide.UNDER: + relaxation_side_map[arg] = RelaxationSide.OVER + else: + assert relaxation_side == RelaxationSide.OVER + relaxation_side_map[arg] = RelaxationSide.UNDER + + +def _relax_root_to_leaf_PowExpression(node, relaxation_side_map): + arg1, arg2 = node.args + relaxation_side_map[arg1] = RelaxationSide.BOTH + relaxation_side_map[arg2] = RelaxationSide.BOTH + + +def _relax_root_to_leaf_sqrt(node, relaxation_side_map): + arg = node.args[0] + relaxation_side_map[arg] = relaxation_side_map[node] + + +def _relax_root_to_leaf_exp(node, relaxation_side_map): + arg = node.args[0] + relaxation_side_map[arg] = relaxation_side_map[node] + + +def _relax_root_to_leaf_log(node, relaxation_side_map): + arg = node.args[0] + relaxation_side_map[arg] = relaxation_side_map[node] + + +def _relax_root_to_leaf_log10(node, relaxation_side_map): + arg = node.args[0] + relaxation_side_map[arg] = relaxation_side_map[node] + + +def _relax_root_to_leaf_sin(node, relaxation_side_map): + arg = node.args[0] + relaxation_side_map[arg] = RelaxationSide.BOTH + + +def _relax_root_to_leaf_cos(node, relaxation_side_map): + arg = node.args[0] + relaxation_side_map[arg] = RelaxationSide.BOTH + + +def _relax_root_to_leaf_arctan(node, relaxation_side_map): + arg = node.args[0] + relaxation_side_map[arg] = RelaxationSide.BOTH + + +def _relax_root_to_leaf_tan(node, relaxation_side_map): + arg = node.args[0] + relaxation_side_map[arg] = RelaxationSide.BOTH + + +_unary_root_to_leaf_map = dict() +_unary_root_to_leaf_map['exp'] = _relax_root_to_leaf_exp +_unary_root_to_leaf_map['log'] = _relax_root_to_leaf_log +_unary_root_to_leaf_map['log10'] = _relax_root_to_leaf_log10 +_unary_root_to_leaf_map['sin'] = _relax_root_to_leaf_sin +_unary_root_to_leaf_map['cos'] = _relax_root_to_leaf_cos +_unary_root_to_leaf_map['atan'] = _relax_root_to_leaf_arctan +_unary_root_to_leaf_map['sqrt'] = _relax_root_to_leaf_sqrt +_unary_root_to_leaf_map['tan'] = _relax_root_to_leaf_tan + + +def _relax_root_to_leaf_UnaryFunctionExpression(node, relaxation_side_map): + if node.getname() in _unary_root_to_leaf_map: + _unary_root_to_leaf_map[node.getname()](node, relaxation_side_map) + else: + raise NotImplementedError('Cannot automatically relax ' + str(node)) + + +def _relax_root_to_leaf_GeneralExpression(node, relaxation_side_map): + relaxation_side = relaxation_side_map[node] + relaxation_side_map[node.expr] = relaxation_side + + +_relax_root_to_leaf_map = dict() +_relax_root_to_leaf_map[numeric_expr.ProductExpression] = ( + _relax_root_to_leaf_ProductExpression +) +_relax_root_to_leaf_map[numeric_expr.SumExpression] = _relax_root_to_leaf_SumExpression +_relax_root_to_leaf_map[numeric_expr.LinearExpression] = ( + _relax_root_to_leaf_SumExpression +) +_relax_root_to_leaf_map[numeric_expr.MonomialTermExpression] = ( + _relax_root_to_leaf_ProductExpression +) +_relax_root_to_leaf_map[numeric_expr.NegationExpression] = ( + _relax_root_to_leaf_NegationExpression +) +_relax_root_to_leaf_map[numeric_expr.PowExpression] = _relax_root_to_leaf_PowExpression +_relax_root_to_leaf_map[numeric_expr.DivisionExpression] = ( + _relax_root_to_leaf_DivisionExpression +) +_relax_root_to_leaf_map[numeric_expr.UnaryFunctionExpression] = ( + _relax_root_to_leaf_UnaryFunctionExpression +) +_relax_root_to_leaf_map[numeric_expr.NPV_ProductExpression] = ( + _relax_root_to_leaf_ProductExpression +) +_relax_root_to_leaf_map[numeric_expr.NPV_SumExpression] = ( + _relax_root_to_leaf_SumExpression +) +_relax_root_to_leaf_map[numeric_expr.NPV_NegationExpression] = ( + _relax_root_to_leaf_NegationExpression +) +_relax_root_to_leaf_map[numeric_expr.NPV_PowExpression] = ( + _relax_root_to_leaf_PowExpression +) +_relax_root_to_leaf_map[numeric_expr.NPV_DivisionExpression] = ( + _relax_root_to_leaf_DivisionExpression +) +_relax_root_to_leaf_map[numeric_expr.NPV_UnaryFunctionExpression] = ( + _relax_root_to_leaf_UnaryFunctionExpression +) +_relax_root_to_leaf_map[_GeneralExpressionData] = _relax_root_to_leaf_GeneralExpression +_relax_root_to_leaf_map[SimpleExpression] = _relax_root_to_leaf_GeneralExpression + + +class _FactorableRelaxationVisitor(ExpressionValueVisitor): + """ + This walker generates new constraints with nonlinear terms replaced by + auxiliary variables, and relaxations relating the auxiliary variables to + the original variables. + """ + + def __init__( + self, aux_var_map, parent_block, relaxation_side_map, counter, degree_map + ): + self.aux_var_map = aux_var_map + self.parent_block = parent_block + self.relaxation_side_map = relaxation_side_map + self.counter = counter + self.degree_map = degree_map + + def visit(self, node, values): + if node.__class__ in _relax_leaf_to_root_map: + res = _relax_leaf_to_root_map[node.__class__]( + node, + values, + self.aux_var_map, + self.degree_map, + self.parent_block, + self.relaxation_side_map, + self.counter, + ) + return res + else: + raise NotImplementedError( + 'Cannot relax an expression of type ' + str(type(node)) + ) + + def visiting_potential_leaf(self, node): + if node.__class__ in nonpyomo_leaf_types: + self.degree_map[node] = 0 + return True, node + + if node.is_variable_type(): + if node.fixed: + self.degree_map[node] = 0 + else: + self.degree_map[node] = 1 + return True, node + + if not node.is_expression_type(): + self.degree_map[node] = 0 + return True, node + + if node.__class__ in _relax_root_to_leaf_map: + _relax_root_to_leaf_map[node.__class__](node, self.relaxation_side_map) + else: + raise NotImplementedError( + 'Cannot relax an expression of type ' + str(type(node)) + ) + + return False, None + + +def _relax_expr( + expr, aux_var_map, parent_block, relaxation_side_map, counter, degree_map +): + visitor = _FactorableRelaxationVisitor( + aux_var_map=aux_var_map, + parent_block=parent_block, + relaxation_side_map=relaxation_side_map, + counter=counter, + degree_map=degree_map, + ) + new_expr = visitor.dfs_postorder_stack(expr) + return new_expr + + +def _relax_cloned_model(m): + """ + Create a convex relaxation of the model. + + Parameters + ---------- + m: pyomo.core.base.block._BlockData or pyomo.core.base.PyomoModel.ConcreteModel + The model or block to be relaxed + """ + if not hasattr(m, 'aux_vars'): + m.aux_vars = pe.VarList() + m.relaxations = pe.Block() + + aux_var_map = dict() + degree_map = ComponentMap() + counter = RelaxationCounter() + + for c in m.nonlinear.cons.values(): + repn = generate_standard_repn(c.body, quadratic=False, compute_values=True) + assert len(repn.quadratic_vars) == 0 + if repn.nonlinear_expr is None: + continue + + cl, cu = c.lb, c.ub + if cl is not None and cu is not None: + relaxation_side = RelaxationSide.BOTH + elif cl is not None: + relaxation_side = RelaxationSide.OVER + elif cu is not None: + relaxation_side = RelaxationSide.UNDER + else: + raise ValueError( + 'Encountered a constraint without a lower or an upper bound: ' + str(c) + ) + + if len(repn.linear_vars) > 0: + new_body = numeric_expr.LinearExpression( + constant=repn.constant, + linear_coefs=repn.linear_coefs, + linear_vars=repn.linear_vars, + ) + else: + new_body = repn.constant + + relaxation_side_map = ComponentMap() + relaxation_side_map[repn.nonlinear_expr] = relaxation_side + + new_body += _relax_expr( + expr=repn.nonlinear_expr, + aux_var_map=aux_var_map, + parent_block=m, + relaxation_side_map=relaxation_side_map, + counter=counter, + degree_map=degree_map, + ) + m.linear.cons.add((cl, new_body, cu)) + + if hasattr(m.nonlinear, 'obj'): + obj = m.nonlinear.obj + if obj.sense == pe.minimize: + relaxation_side = RelaxationSide.UNDER + elif obj.sense == pe.maximize: + relaxation_side = RelaxationSide.OVER + else: + raise ValueError( + 'Encountered an objective with an unrecognized sense: ' + str(obj) + ) + + repn = generate_standard_repn(obj.expr, quadratic=False, compute_values=True) + assert len(repn.quadratic_vars) == 0 + assert repn.nonlinear_expr is not None + if len(repn.linear_vars) > 0: + new_body = numeric_expr.LinearExpression( + constant=repn.constant, + linear_coefs=repn.linear_coefs, + linear_vars=repn.linear_vars, + ) + else: + new_body = repn.constant + + relaxation_side_map = ComponentMap() + relaxation_side_map[repn.nonlinear_expr] = relaxation_side + + new_body += _relax_expr( + expr=repn.nonlinear_expr, + aux_var_map=aux_var_map, + parent_block=m, + relaxation_side_map=relaxation_side_map, + counter=counter, + degree_map=degree_map, + ) + m.linear.obj = pe.Objective(expr=new_body, sense=obj.sense) + + del m.nonlinear + + for relaxation in relaxation_data_objects(m, descend_into=True, active=True): + relaxation.rebuild() + + +def relax(model): + """ + Create a convex relaxation of the model. + + Parameters + ---------- + model: pyomo.core.base.block._BlockData or pyomo.core.base.PyomoModel.ConcreteModel + The model or block to be relaxed + + Returns + ------- + m: pyomo.core.base.block._BlockData or pyomo.core.base.PyomoModel.ConcreteModel + The relaxed model + """ + m = clone_shallow_active_flat(model)[0] + _relax_cloned_model(m) + return m diff --git a/pyomo/contrib/coramin/relaxations/copy_relaxation.py b/pyomo/contrib/coramin/relaxations/copy_relaxation.py new file mode 100644 index 00000000000..98173a5d949 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/copy_relaxation.py @@ -0,0 +1,175 @@ +from .mccormick import PWMcCormickRelaxationData, PWMcCormickRelaxation +from .univariate import ( + PWXSquaredRelaxationData, + PWUnivariateRelaxationData, + PWArctanRelaxationData, + PWCosRelaxationData, + PWSinRelaxationData, +) +from .univariate import ( + PWXSquaredRelaxation, + PWUnivariateRelaxation, + PWArctanRelaxation, + PWCosRelaxation, + PWSinRelaxation, +) +from .alphabb import AlphaBBRelaxation, AlphaBBRelaxationData +from .multivariate import MultivariateRelaxationData, MultivariateRelaxation +from pyomo.core.expr.visitor import replace_expressions +from pyomo.contrib.coramin.utils.coramin_enums import FunctionShape + + +def copy_relaxation_with_local_data(rel, old_var_to_new_var_map=None): + """ + This function copies a relaxation object with new variables. + Note that only what can be set through the set_input and build + methods are copied. For example, piecewise partitioning points + are not copied. + + Parameters + ---------- + rel: coramin.relaxations.relaxations_base.BaseRelaxationData + The relaxation to be copied + old_var_to_new_var_map: dict + Map from the original variable id to the new variable + + Returns + ------- + rel: coramin.relaxations.relaxations_base.BaseRelaxationData + The copy of rel with new variables + """ + if old_var_to_new_var_map is None: + old_var_to_new_var_map = dict() + for v in rel.get_rhs_vars(): + old_var_to_new_var_map[id(v)] = v + aux_var = rel.get_aux_var() + old_var_to_new_var_map[id(aux_var)] = aux_var + + if isinstance(rel, PWXSquaredRelaxationData): + new_x = old_var_to_new_var_map[id(rel.get_rhs_vars()[0])] + new_aux_var = old_var_to_new_var_map[id(rel.get_aux_var())] + new_rel = PWXSquaredRelaxation(concrete=True) + new_rel.set_input( + x=new_x, + aux_var=new_aux_var, + pw_repn=rel._pw_repn, + use_linear_relaxation=rel.use_linear_relaxation, + relaxation_side=rel.relaxation_side, + ) + elif isinstance(rel, PWArctanRelaxationData): + new_x = old_var_to_new_var_map[id(rel.get_rhs_vars()[0])] + new_aux_var = old_var_to_new_var_map[id(rel.get_aux_var())] + new_rel = PWArctanRelaxation(concrete=True) + new_rel.set_input( + x=new_x, + aux_var=new_aux_var, + pw_repn=rel._pw_repn, + relaxation_side=rel.relaxation_side, + use_linear_relaxation=rel.use_linear_relaxation, + ) + elif isinstance(rel, PWSinRelaxationData): + new_x = old_var_to_new_var_map[id(rel.get_rhs_vars()[0])] + new_aux_var = old_var_to_new_var_map[id(rel.get_aux_var())] + new_rel = PWSinRelaxation(concrete=True) + new_rel.set_input( + x=new_x, + aux_var=new_aux_var, + pw_repn=rel._pw_repn, + relaxation_side=rel.relaxation_side, + use_linear_relaxation=rel.use_linear_relaxation, + ) + elif isinstance(rel, PWCosRelaxationData): + new_x = old_var_to_new_var_map[id(rel.get_rhs_vars()[0])] + new_aux_var = old_var_to_new_var_map[id(rel.get_aux_var())] + new_rel = PWCosRelaxation(concrete=True) + new_rel.set_input( + x=new_x, + aux_var=new_aux_var, + pw_repn=rel._pw_repn, + relaxation_side=rel.relaxation_side, + use_linear_relaxation=rel.use_linear_relaxation, + ) + elif isinstance(rel, PWUnivariateRelaxationData): + new_x = old_var_to_new_var_map[id(rel.get_rhs_vars()[0])] + new_aux_var = old_var_to_new_var_map[id(rel.get_aux_var())] + new_f_x_expr = replace_expressions( + rel.get_rhs_expr(), + substitution_map=old_var_to_new_var_map, + remove_named_expressions=True, + ) + new_rel = PWUnivariateRelaxation(concrete=True) + if rel.is_rhs_convex(): + shape = FunctionShape.CONVEX + elif rel.is_rhs_concave(): + shape = FunctionShape.CONCAVE + else: + shape = FunctionShape.UNKNOWN + new_rel.set_input( + x=new_x, + aux_var=new_aux_var, + shape=shape, + f_x_expr=new_f_x_expr, + pw_repn=rel._pw_repn, + relaxation_side=rel.relaxation_side, + use_linear_relaxation=rel.use_linear_relaxation, + ) + elif isinstance(rel, PWMcCormickRelaxationData): + rhs_vars = rel.get_rhs_vars() + old_x1 = rhs_vars[0] + old_x2 = rhs_vars[1] + new_x1 = old_var_to_new_var_map[id(old_x1)] + new_x2 = old_var_to_new_var_map[id(old_x2)] + new_aux_var = old_var_to_new_var_map[id(rel.get_aux_var())] + new_rel = PWMcCormickRelaxation(concrete=True) + new_rel.set_input( + x1=new_x1, + x2=new_x2, + aux_var=new_aux_var, + relaxation_side=rel.relaxation_side, + ) + elif isinstance(rel, AlphaBBRelaxationData): + new_aux_var = old_var_to_new_var_map[id(rel.get_aux_var())] + new_f_x_expr = replace_expressions( + rel.get_rhs_expr(), + substitution_map=old_var_to_new_var_map, + remove_named_expressions=True, + ) + new_rel = AlphaBBRelaxation(concrete=True) + new_rel.set_input( + aux_var=new_aux_var, + f_x_expr=new_f_x_expr, + relaxation_side=rel.relaxation_side, + use_linear_relaxation=rel.use_linear_relaxation, + eigenvalue_bounder=rel.hessian.method, + eigenvalue_opt=rel.hessian.opt, + ) + elif isinstance(rel, MultivariateRelaxationData): + new_aux_var = old_var_to_new_var_map[id(rel.get_aux_var())] + if rel.is_rhs_convex(): + shape = FunctionShape.CONVEX + elif rel.is_rhs_concave(): + shape = FunctionShape.CONCAVE + else: + shape = FunctionShape.UNKNOWN + new_f_x_expr = replace_expressions( + rel.get_rhs_expr(), + substitution_map=old_var_to_new_var_map, + remove_named_expressions=True, + ) + new_rel = MultivariateRelaxation(concrete=True) + new_rel.set_input( + aux_var=new_aux_var, + shape=shape, + f_x_expr=new_f_x_expr, + use_linear_relaxation=rel.use_linear_relaxation, + ) + else: + raise ValueError('Unrecognized relaxation: {0}'.format(str(type(rel)))) + + new_rel.small_coef = rel.small_coef + new_rel.large_coef = rel.large_coef + new_rel.safety_tol = rel.safety_tol + + new_rel.rebuild() + + return new_rel diff --git a/pyomo/contrib/coramin/relaxations/custom_block.py b/pyomo/contrib/coramin/relaxations/custom_block.py new file mode 100644 index 00000000000..06f16980072 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/custom_block.py @@ -0,0 +1,93 @@ +import sys +from pyomo.core.base.block import Block +from pyomo.core.base.indexed_component import UnindexedComponent_set + +# ToDo: documentation +# ToDo: passing of kwargs down to the data object +# ToDo: figure out if the setattr's are necessary in the decorator +# ToDo: decide if we need the decorator (it actually does not do much +# and can be replaced by one more class declaration that might be "easier" +# for the user anyway? +''' +This module implements meta classes and a decorator to make +it easier to create derived block types. With the decorator, +you only need to inherit from _BlockData. + +# ToDo: Document this custom block code with an example +''' + + +class _IndexedCustomBlockMeta(type): + """Metaclass for creating an indexed block with + a custom block data type.""" + + def __new__(meta, name, bases, dct): + def __init__(self, *args, **kwargs): + bases[0].__init__(self, *args, **kwargs) + + dct["__init__"] = __init__ + return type.__new__(meta, name, bases, dct) + + +class _ScalarCustomBlockMeta(type): + '''Metaclass used to create a scalar block with a + custom block data type + ''' + + def __new__(meta, name, bases, dct): + def __init__(self, *args, **kwargs): + # bases[0] is the custom block data object + bases[0].__init__(self, component=self) + # bases[1] is the custom block object that + # is used for declaration + bases[1].__init__(self, *args, **kwargs) + + dct["__init__"] = __init__ + return type.__new__(meta, name, bases, dct) + + +class CustomBlock(Block): + '''This CustomBlock is the base class that allows + for easy creation of specialized derived blocks + ''' + + def __new__(cls, *args, **kwds): + if cls.__name__.startswith('_Indexed') or cls.__name__.startswith('_Scalar'): + # we are entering here the second time (recursive) + # therefore, we need to create what we have + return super(CustomBlock, cls).__new__(cls) + if not args or (args[0] is UnindexedComponent_set and len(args) == 1): + bname = "_Scalar{}".format(cls.__name__) + n = _ScalarCustomBlockMeta(bname, (cls._ComponentDataClass, cls), {}) + return n.__new__(n) + else: + bname = "_Indexed{}".format(cls.__name__) + n = _IndexedCustomBlockMeta(bname, (cls,), {}) + return n.__new__(n) + + +def declare_custom_block(name): + '''Decorator to declare the custom component + that goes along with a custom block data + + @declare_custom_block(name=FooBlock) + class FooBlockData(_BlockData): + # custom block data class + ''' + + def proc_dec(cls): + # this is the decorator function that + # creates the block component class + c = type( + name, # name of new class + (CustomBlock,), # base classes + {"__module__": cls.__module__, "_ComponentDataClass": cls}, + ) # magic to fix the module + + # are these necessary? + setattr(sys.modules[cls.__module__], name, c) + setattr(cls, '_orig_name', name) + setattr(cls, '_orig_module', cls.__module__) + return cls + + return proc_dec diff --git a/pyomo/contrib/coramin/relaxations/hessian.py b/pyomo/contrib/coramin/relaxations/hessian.py new file mode 100644 index 00000000000..fa0a2650ef9 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/hessian.py @@ -0,0 +1,301 @@ +from pyomo.core.expr.calculus.diff_with_pyomo import reverse_sd +from pyomo.core.expr.visitor import identify_variables +from pyomo.common.collections import ComponentSet +import enum +import pyomo.environ as pe +from pyomo.core.expr.numvalue import is_fixed +import math +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr +from pyomo.common.dependencies import numpy as np +from pyomo.contrib.coramin.utils.coramin_enums import EigenValueBounder +from pyomo.core.base.block import _BlockData +from typing import Optional, MutableMapping +from pyomo.core.expr.numeric_expr import ExpressionBase +from pyomo.contrib import appsi +from pyomo.common.modeling import unique_component_name +from pyomo.core.base.var import _GeneralVarData +from pyomo.contrib.coramin.utils.pyomo_utils import simplify_expr + + +def _2d_determinant(mat: np.ndarray): + return mat[0, 0] * mat[1, 1] - mat[1, 0] * mat[0, 1] + + +def _determinant(mat): + nrows, ncols = mat.shape + assert nrows == ncols + if nrows == 1: + det = mat[0, 0] + elif nrows == 2: + det = _2d_determinant(mat) + else: + i = 0 + det = 0 + next_rows = np.array(list(range(i + 1, nrows)), dtype=int) + for j in range(nrows): + next_cols = [k for k in range(j)] + next_cols.extend(k for k in range(j + 1, nrows)) + next_cols = np.array(next_cols, dtype=int) + next_mat = mat[next_rows, :] + next_mat = next_mat[:, next_cols] + det += (-1) ** (i + j) * mat[i, j] * _determinant(next_mat) + return simplify_expr(det) + + +class Hessian(object): + def __init__( + self, + expr: ExpressionBase, + opt: Optional[appsi.base.Solver], + method: EigenValueBounder = EigenValueBounder.LinearProgram, + ): + self.method = EigenValueBounder(method) + self.opt = opt + self._constant_hessian = False + self._constant_hessian_min_eig = None + self._constant_hessian_max_eig = None + self._expr = expr + self._var_list = list(identify_variables(expr=expr, include_fixed=False)) + self._ndx_map = pe.ComponentMap( + (v, ndx) for ndx, v in enumerate(self._var_list) + ) + self._hessian = self.compute_symbolic_hessian() + self._eigenvalue_problem: Optional[_BlockData] = None + self._eigenvalue_relaxation: Optional[_BlockData] = None + self._orig_to_relaxation_vars: Optional[ + MutableMapping[_GeneralVarData, _GeneralVarData] + ] = None + + def variables(self): + return tuple(self._var_list) + + def formulate_eigenvalue_problem(self, sense=pe.minimize): + if self._eigenvalue_problem is not None: + min_eig, max_eig = self.bound_eigenvalues_from_interval_hessian() + if min_eig > self._eigenvalue_problem.eig.lb: + self._eigenvalue_problem.eig.setlb(min_eig) + if max_eig < self._eigenvalue_problem.eig.ub: + self._eigenvalue_problem.eig.setub(max_eig) + self._eigenvalue_problem.obj.sense = sense + return self._eigenvalue_problem + min_eig, max_eig = self.bound_eigenvalues_from_interval_hessian() + m = pe.ConcreteModel() + m.eig = pe.Var(bounds=(min_eig, max_eig)) + m.obj = pe.Objective(expr=m.eig, sense=sense) + for v in self._var_list: + m.add_component(v.name, pe.Reference(v)) + + n = len(self._var_list) + np_hess = np.empty((n, n), dtype=object) + for ndx1, v1 in enumerate(self._var_list): + hess_v1 = self._hessian[v1] + for ndx2 in range(ndx1, n): + v2 = self._var_list[ndx2] + if v2 in hess_v1: + np_hess[ndx1, ndx2] = hess_v1[v2] + else: + np_hess[ndx1, ndx2] = 0 + if v1 is v2: + np_hess[ndx1, ndx2] -= m.eig + else: + np_hess[ndx2, ndx1] = np_hess[ndx1, ndx2] + m.det_con = pe.Constraint(expr=_determinant(np_hess) == 0) + self._eigenvalue_problem = m + return m + + def formulate_eigenvalue_relaxation(self, sense=pe.minimize): + if self._eigenvalue_relaxation is not None: + for orig_v, rel_v in self._orig_to_relaxation_vars.items(): + orig_lb, orig_ub = orig_v.bounds + rel_lb, rel_ub = rel_v.bounds + if orig_lb is not None: + if rel_lb is None or orig_lb > rel_lb: + rel_v.setlb(orig_lb) + if orig_ub is not None: + if rel_ub is None or orig_ub < rel_ub: + rel_v.setub(orig_ub) + from .iterators import relaxation_data_objects + + for b in relaxation_data_objects( + self._eigenvalue_relaxation, descend_into=True, active=True + ): + b.rebuild() + self._eigenvalue_relaxation.obj.sense = sense + return self._eigenvalue_relaxation + m = self.formulate_eigenvalue_problem(sense=sense) + all_vars = list( + ComponentSet(m.component_data_objects(pe.Var, descend_into=True)) + ) + from .auto_relax import relax + + relaxation = relax(m) + self._eigenvalue_relaxation = relaxation + return relaxation + + def _compute_eigenvalues_of_constant_hessian(self): + assert self._constant_hessian + nvars = len(self._var_list) + h = np.zeros(shape=(nvars, nvars), dtype=float) + for v1, d1 in self._hessian.items(): + ndx1 = self._ndx_map[v1] + for v2, d2 in d1.items(): + ndx2 = self._ndx_map[v2] + h[ndx1, ndx2] = d2 + eigvals = np.linalg.eigvals(h) + self._constant_hessian_min_eig = np.min(eigvals) + self._constant_hessian_max_eig = np.max(eigvals) + + def get_minimum_eigenvalue(self): + if self._constant_hessian: + if self._constant_hessian_min_eig is None: + self._compute_eigenvalues_of_constant_hessian() + res = self._constant_hessian_min_eig + elif self.method <= EigenValueBounder.GershgorinWithSimplification: + res = self.bound_eigenvalues_from_interval_hessian()[0] + elif self.method == EigenValueBounder.LinearProgram: + m = self.formulate_eigenvalue_relaxation() + res = self.opt.solve(m).best_objective_bound + else: + m = self.formulate_eigenvalue_problem() + res = self.opt.solve(m).best_objective_bound + return res + + def get_maximum_eigenvalue(self): + if self._constant_hessian: + if self._constant_hessian_max_eig is None: + self._compute_eigenvalues_of_constant_hessian() + res = self._constant_hessian_max_eig + elif self.method <= EigenValueBounder.GershgorinWithSimplification: + res = self.bound_eigenvalues_from_interval_hessian()[1] + elif self.method == EigenValueBounder.LinearProgram: + m = self.formulate_eigenvalue_relaxation(sense=pe.maximize) + res = self.opt.solve(m).best_objective_bound + else: + m = self.formulate_eigenvalue_problem(sense=pe.maximize) + res = self.opt.solve(m).best_objective_bound + return res + + def bound_eigenvalues_from_interval_hessian(self): + ih = self.compute_interval_hessian() + min_eig = math.inf + max_eig = -math.inf + for v1 in self._var_list: + h = ih[v1] + if v1 in h: + row_min = h[v1][0] + row_max = h[v1][1] + else: + row_min = 0 + row_max = 0 + for v2, (lb, ub) in h.items(): + if v2 is v1: + continue + row_min -= max(abs(lb), abs(ub)) + row_max += max(abs(lb), abs(ub)) + min_eig = min(min_eig, row_min) + max_eig = max(max_eig, row_max) + return min_eig, max_eig + + def compute_symbolic_hessian(self): + ders = reverse_sd(self._expr) + ders2 = pe.ComponentMap() + for v in self._var_list: + ders2[v] = reverse_sd(ders[v]) + + res = pe.ComponentMap() + for v in self._var_list: + res[v] = pe.ComponentMap() + + n = len(self._var_list) + for v1 in self._var_list: + ndx1 = self._ndx_map[v1] + for ndx2 in range(ndx1, n): + v2 = self._var_list[ndx2] + if v2 not in ders2[v1]: + continue + der = ders2[v1][v2] + if is_fixed(der): + val = pe.value(der) + res[v1][v2] = val + else: + if self.method >= EigenValueBounder.GershgorinWithSimplification: + _der = simplify_expr(der) + else: + _der = der + res[v1][v2] = _der + res[v2][v1] = res[v1][v2] + + self._constant_hessian = True + for v1, dd in res.items(): + for v2, d2 in dd.items(): + if is_fixed(d2): + res[v1][v2] = pe.value(d2) + else: + self._constant_hessian = False + + return res + + def compute_interval_hessian(self): + res = pe.ComponentMap() + for v in self._var_list: + res[v] = pe.ComponentMap() + + n = len(self._var_list) + for ndx1, v1 in enumerate(self._var_list): + for ndx2 in range(ndx1, n): + v2 = self._var_list[ndx2] + if v2 not in self._hessian[v1]: + continue + lb, ub = compute_bounds_on_expr(self._hessian[v1][v2]) + if lb is None: + lb = -math.inf + if ub is None: + ub = math.inf + res[v1][v2] = (lb, ub) + res[v2][v1] = (lb, ub) + return res + + def pprint(self, intervals=False): + if intervals: + ih = self.compute_interval_hessian() + else: + ih = self._hessian + n = len(self._var_list) + lens = np.ones((n, n), dtype=int) + strs = dict() + for ndx in range(n): + strs[ndx] = dict() + + for v1 in self._var_list: + ndx1 = self._ndx_map[v1] + for ndx2 in range(ndx1, n): + v2 = self._var_list[ndx2] + if v2 in ih[v1]: + der = ih[v1][v2] + else: + if intervals: + der = (0, 0) + else: + der = 0 + if intervals: + lb, ub = der + der_str = f"({lb:<.3f}, {ub:<.3f})" + else: + der_str = str(der) + strs[ndx1][ndx2] = der_str + strs[ndx2][ndx1] = der_str + lens[ndx1, ndx2] = len(der_str) + lens[ndx2, ndx1] = len(der_str) + + col_lens = np.max(lens, axis=0) + row_string = "" + for ndx, cl in enumerate(col_lens): + row_string += f"{{{ndx}:<{cl+2}}}" + + res = "" + for row_ndx in range(n): + row_entries = tuple(strs[row_ndx][i] for i in range(n)) + res += row_string.format(*row_entries) + res += '\n' + + print(res) diff --git a/pyomo/contrib/coramin/relaxations/iterators.py b/pyomo/contrib/coramin/relaxations/iterators.py new file mode 100644 index 00000000000..de797a307c4 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/iterators.py @@ -0,0 +1,95 @@ +import pyomo.environ as pe +from .relaxations_base import BaseRelaxationData +from typing import Generator + + +def relaxation_data_objects( + block, descend_into=True, active=None, sort=False +) -> Generator[BaseRelaxationData, None, None]: + """ + Iterate over all instances of BaseRelaxationData in the block. + + Parameters + ---------- + block: pyomo.core.base.block._BlockData + The Block in which to look for relaxations + descend_into: bool + Whether or not to look for relaxations in sub-blocks + active: bool + If True, then any relaxations that have been deactivated or live on deactivated blocks will not be returned. + sort: bool + + Returns + ------- + relaxations: generator + A generator yielding the relaxation objects. + """ + for b in block.component_data_objects( + pe.Block, descend_into=descend_into, active=active, sort=sort + ): + if isinstance(b, BaseRelaxationData): + yield b + + +def _nonrelaxation_block_objects(block, descend_into=True, active=None, sort=False): + for b in block.component_data_objects( + pe.Block, descend_into=False, active=active, sort=sort + ): + if isinstance(b, BaseRelaxationData): + continue + else: + yield b + if descend_into: + for _b in _nonrelaxation_block_objects( + b, descend_into=True, active=active, sort=sort + ): + yield _b + + +def nonrelaxation_component_data_objects( + block, ctype=None, active=None, sort=False, descend_into=True +): + """ + Iterate over all components with the corresponding ctype (e.g., Constraint) in the block excluding + those instances which are or live on relaxation objects (instances of BaseRelaxationData). + + Parameters + ---------- + block: pyomo.core.base.block._BlockData + The Block in which to look for components + ctype: type + The type of component to iterate over + descend_into: bool + Whether or not to look for components in sub-blocks + active: bool + If True, then any components that have been deactivated or live on deactivated blocks will not be returned. + sort: bool + + Returns + ------- + components: generator + A generator yielding the requested components. + """ + if not isinstance(ctype, type): + raise ValueError( + "nonrelaxation_component_data_objects expects ctype to be a type, not a " + + str(type(ctype)) + ) + if ctype is pe.Block: + for b in _nonrelaxation_block_objects( + block, descend_into=descend_into, active=active, sort=sort + ): + yield b + else: + for comp in block.component_data_objects( + ctype=ctype, descend_into=False, active=active, sort=sort + ): + yield comp + if descend_into: + for b in _nonrelaxation_block_objects( + block, descend_into=True, active=active, sort=sort + ): + for comp in b.component_data_objects( + ctype=ctype, descend_into=False, active=active, sort=sort + ): + yield comp diff --git a/pyomo/contrib/coramin/relaxations/mccormick.py b/pyomo/contrib/coramin/relaxations/mccormick.py new file mode 100644 index 00000000000..79e5c9bb4de --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/mccormick.py @@ -0,0 +1,401 @@ +import logging +import pyomo.environ as pyo +from pyomo.contrib.coramin.utils.coramin_enums import RelaxationSide +from .custom_block import declare_custom_block +from .relaxations_base import BasePWRelaxationData, _check_cut +import math +from ._utils import check_var_pts, _get_bnds_list, _get_bnds_tuple +from pyomo.core.base.param import IndexedParam +from pyomo.core.base.constraint import IndexedConstraint +from pyomo.core.expr.numeric_expr import LinearExpression +from typing import Optional, Dict, Sequence + +pe = pyo + +logger = logging.getLogger(__name__) + + +def _build_pw_mccormick_relaxation( + b, x1, x2, aux_var, x1_pts, relaxation_side=RelaxationSide.BOTH, safety_tol=1e-10 +): + """ + This function creates piecewise envelopes to relax "aux_var = x1*x2". Note that the partitioning is done on "x1" only. + This is the "nf4r" from Gounaris, Misener, and Floudas (2009). + + Parameters + ---------- + b: pyo.ConcreteModel or pyo.Block + x1: pyomo.core.base.var._GeneralVarData + The "x1" variable in x1*x2 + x2: pyomo.core.base.var._GeneralVarData + The "x2" variable in x1*x2 + aux_var: pyomo.core.base.var._GeneralVarData + The "aux_var" variable that is replacing x*y + x1_pts: Sequence[float] + A list of floating point numbers to define the points over which the piecewise representation will generated. + This list must be ordered, and it is expected that the first point (x_pts[0]) is equal to x.lb and the + last point (x_pts[-1]) is equal to x.ub + relaxation_side : minlp.minlp_defn.RelaxationSide + Provide the desired side for the relaxation (OVER, UNDER, or BOTH) + """ + assert len(x1_pts) > 2 + + x1_lb = x1_pts[0] + x1_ub = x1_pts[-1] + x2_lb, x2_ub = tuple(_get_bnds_list(x2)) + + check_var_pts(x1, x_pts=x1_pts) + check_var_pts(x2) + + if x1.is_fixed() and x2.is_fixed(): + b.x1_x2_fixed_eq = pyo.Constraint(expr=aux_var == pyo.value(x1) * pyo.value(x2)) + elif x1.is_fixed(): + b.x1_fixed_eq = pyo.Constraint(expr=aux_var == pyo.value(x1) * x2) + elif x2.is_fixed(): + b.x2_fixed_eq = pyo.Constraint(expr=aux_var == x1 * pyo.value(x2)) + else: + # create the lambda_ variables (binaries for the pw representation) + b.interval_set = pyo.Set(initialize=range(1, len(x1_pts))) + b.lambda_ = pyo.Var(b.interval_set, within=pyo.Binary) + + # create the delta x2 variables + b.delta_x2 = pyo.Var(b.interval_set, bounds=(0, None)) + + # create the "sos1" constraint + b.lambda_sos1 = pyo.Constraint( + expr=sum(b.lambda_[n] for n in b.interval_set) == 1.0 + ) + + # create the x1 interval constraints + b.x1_interval_lb = pyo.Constraint( + expr=sum(x1_pts[n - 1] * b.lambda_[n] for n in b.interval_set) <= x1 + ) + b.x1_interval_ub = pyo.Constraint( + expr=x1 <= sum(x1_pts[n] * b.lambda_[n] for n in b.interval_set) + ) + + # create the x2 constraints + b.x2_con = pyo.Constraint( + expr=x2 == x2_lb + sum(b.delta_x2[n] for n in b.interval_set) + ) + + def delta_x2n_ub_rule(m, n): + return b.delta_x2[n] <= (x2_ub - x2_lb) * b.lambda_[n] + + b.delta_x2n_ub = pyo.Constraint(b.interval_set, rule=delta_x2n_ub_rule) + + # create the relaxation constraints + if ( + relaxation_side == RelaxationSide.UNDER + or relaxation_side == RelaxationSide.BOTH + ): + b.aux_var_lb1 = pyo.Constraint( + expr=( + aux_var + >= x2_ub * x1 + + sum(x1_pts[n] * b.delta_x2[n] for n in b.interval_set) + - (x2_ub - x2_lb) + * sum(x1_pts[n] * b.lambda_[n] for n in b.interval_set) + - safety_tol + ) + ) + b.aux_var_lb2 = pyo.Constraint( + expr=aux_var + >= x2_lb * x1 + + sum(x1_pts[n - 1] * b.delta_x2[n] for n in b.interval_set) + - safety_tol + ) + + if ( + relaxation_side == RelaxationSide.OVER + or relaxation_side == RelaxationSide.BOTH + ): + b.aux_var_ub1 = pyo.Constraint( + expr=( + aux_var + <= x2_ub * x1 + + sum(x1_pts[n - 1] * b.delta_x2[n] for n in b.interval_set) + - (x2_ub - x2_lb) + * sum(x1_pts[n - 1] * b.lambda_[n] for n in b.interval_set) + + safety_tol + ) + ) + b.aux_var_ub2 = pyo.Constraint( + expr=aux_var + <= x2_lb * x1 + + sum(x1_pts[n] * b.delta_x2[n] for n in b.interval_set) + + safety_tol + ) + + +@declare_custom_block(name='PWMcCormickRelaxation') +class PWMcCormickRelaxationData(BasePWRelaxationData): + """ + A class for managing McCormick relaxations of bilinear terms (aux_var = x1 * x2). + """ + + def __init__(self, component): + BasePWRelaxationData.__init__(self, component) + self._x1 = None + self._x2 = None + self._aux_var = None + self._f_x_expr = None + self._mc_index = None + self._slopes_index = None + self._v_index = None + self._slopes: Optional[IndexedParam] = None + self._intercepts: Optional[IndexedParam] = None + self._mccormicks: Optional[IndexedConstraint] = None + self._mc_exprs: Dict[int, LinearExpression] = dict() + self._pw = None + + def get_rhs_vars(self): + return self._x1, self._x2 + + def get_rhs_expr(self): + return self._f_x_expr + + def vars_with_bounds_in_relaxation(self): + return [self._x1, self._x2] + + def _remove_relaxation(self): + del ( + self._slopes, + self._intercepts, + self._mccormicks, + self._pw, + self._mc_index, + self._v_index, + self._slopes_index, + ) + self._mc_index = None + self._v_index = None + self._slopes_index = None + self._slopes = None + self._intercepts = None + self._mccormicks = None + self._mc_exprs = dict() + self._pw = None + + def set_input( + self, + x1, + x2, + aux_var, + relaxation_side=RelaxationSide.BOTH, + large_coef=1e5, + small_coef=1e-10, + safety_tol=1e-10, + ): + """ + Parameters + ---------- + x1 : pyomo.core.base.var._GeneralVarData + The "x1" variable in x1*x2 + x2 : pyomo.core.base.var._GeneralVarData + The "x2" variable in x1*x2 + aux_var : pyomo.core.base.var._GeneralVarData + The "aux_var" auxiliary variable that is replacing x1*x2 + relaxation_side : minlp.minlp_defn.RelaxationSide + Provide the desired side for the relaxation (OVER, UNDER, or BOTH) + """ + super(PWMcCormickRelaxationData, self).set_input( + relaxation_side=relaxation_side, + use_linear_relaxation=True, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + ) + object.__setattr__(self, '_x1', x1) + object.__setattr__(self, '_x2', x2) + object.__setattr__(self, '_aux_var', aux_var) + self._partitions[self._x1] = _get_bnds_list(self._x1) + self._f_x_expr = x1 * x2 + + def build( + self, + x1, + x2, + aux_var, + relaxation_side=RelaxationSide.BOTH, + large_coef=1e5, + small_coef=1e-10, + safety_tol=1e-10, + ): + """ + Parameters + ---------- + x1 : pyomo.core.base.var._GeneralVarData + The "x1" variable in x1*x2 + x2 : pyomo.core.base.var._GeneralVarData + The "x2" variable in x1*x2 + aux_var : pyomo.core.base.var._GeneralVarData + The "aux_var" auxiliary variable that is replacing x1*x2 + relaxation_side : minlp.minlp_defn.RelaxationSide + Provide the desired side for the relaxation (OVER, UNDER, or BOTH) + """ + self.set_input( + x1=x1, + x2=x2, + aux_var=aux_var, + relaxation_side=relaxation_side, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + ) + self.rebuild() + + def remove_relaxation(self): + super(PWMcCormickRelaxationData, self).remove_relaxation() + self._remove_relaxation() + + def rebuild(self, build_nonlinear_constraint=False, ensure_oa_at_vertices=True): + build_nonlinear_constraint = ( + build_nonlinear_constraint + or self._x1.is_fixed() + or self._x2.is_fixed() + or (self._x1.lb == self._x1.ub and self._x1.lb is not None) + or (self._x2.lb == self._x2.ub and self._x2.lb is not None) + ) + super(PWMcCormickRelaxationData, self).rebuild( + build_nonlinear_constraint=build_nonlinear_constraint, + ensure_oa_at_vertices=ensure_oa_at_vertices, + ) + if not build_nonlinear_constraint: + if self._check_valid_domain_for_relaxation(): + if len(self._partitions[self._x1]) == 2: + if self._mccormicks is None: + self._remove_relaxation() + self._build_mccormicks() + self._update_mccormicks() + else: + self._remove_relaxation() + del self._pw + self._pw = pe.Block(concrete=True) + _build_pw_mccormick_relaxation( + b=self._pw, + x1=self._x1, + x2=self._x2, + aux_var=self._aux_var, + x1_pts=self._partitions[self._x1], + relaxation_side=self.relaxation_side, + safety_tol=self.safety_tol, + ) + else: + self._remove_relaxation() + + def _build_mccormicks(self): + del ( + self._mc_index, + self._v_index, + self._slopes_index, + self._slopes, + self._intercepts, + self._mccormicks, + ) + self._mc_exprs = dict() + self._mc_index = pe.Set(initialize=[0, 1, 2, 3]) + self._v_index = pe.Set(initialize=[1, 2]) + self._slopes_index = pe.Set(initialize=self._mc_index * self._v_index) + self._slopes = IndexedParam(self._slopes_index, mutable=True) + self._intercepts = IndexedParam(self._mc_index, mutable=True) + self._mccormicks = IndexedConstraint(self._mc_index) + + if self.relaxation_side in {RelaxationSide.BOTH, RelaxationSide.UNDER}: + for ndx in [0, 1]: + e = LinearExpression( + constant=self._intercepts[ndx], + linear_coefs=[self._slopes[ndx, 1], self._slopes[ndx, 2]], + linear_vars=[self._x1, self._x2], + ) + self._mc_exprs[ndx] = e + self._mccormicks[ndx] = self._aux_var >= e + + if self.relaxation_side in {RelaxationSide.BOTH, RelaxationSide.OVER}: + for ndx in [2, 3]: + e = LinearExpression( + constant=self._intercepts[ndx], + linear_coefs=[self._slopes[ndx, 1], self._slopes[ndx, 2]], + linear_vars=[self._x1, self._x2], + ) + self._mc_exprs[ndx] = e + self._mccormicks[ndx] = self._aux_var <= e + + def _check_expr(self, ndx): + if ndx in {0, 1}: + rel_side = RelaxationSide.UNDER + else: + rel_side = RelaxationSide.OVER + success, bad_var, bad_coef, err_msg = _check_cut( + self._mc_exprs[ndx], + too_small=self.small_coef, + too_large=self.large_coef, + relaxation_side=rel_side, + safety_tol=self.safety_tol, + ) + if not success: + self._log_bad_cut(bad_var, bad_coef, err_msg) + self._mccormicks[ndx].deactivate() + else: + self._mccormicks[ndx].activate() + + def _update_mccormicks(self): + x1_lb, x1_ub = _get_bnds_tuple(self._x1) + x2_lb, x2_ub = _get_bnds_tuple(self._x2) + + if self.relaxation_side in {RelaxationSide.BOTH, RelaxationSide.UNDER}: + self._slopes[0, 1]._value = x2_lb + self._slopes[0, 2]._value = x1_lb + self._intercepts[0]._value = -x1_lb * x2_lb + + self._slopes[1, 1]._value = x2_ub + self._slopes[1, 2]._value = x1_ub + self._intercepts[1]._value = -x1_ub * x2_ub + + self._check_expr(0) + self._check_expr(1) + + if self.relaxation_side in {RelaxationSide.BOTH, RelaxationSide.OVER}: + self._slopes[2, 1]._value = x2_lb + self._slopes[2, 2]._value = x1_ub + self._intercepts[2]._value = -x1_ub * x2_lb + + self._slopes[3, 1]._value = x2_ub + self._slopes[3, 2]._value = x1_lb + self._intercepts[3]._value = -x1_lb * x2_ub + + self._check_expr(2) + self._check_expr(3) + + def add_partition_point(self, value=None): + """ + This method adds one point to the partitioning of x1. If value is not + specified, a single point will be added to the partitioning of x1 at the current value of x1. If value is + specified, then value is added to the partitioning of x1. + + Parameters + ---------- + value: float + The point to be added to the partitioning of x1. + """ + self._add_partition_point(self._x1, value) + + def is_rhs_convex(self): + """ + Returns True if linear underestimators do not need binaries. Otherwise, returns False. + + Returns + ------- + bool + """ + return False + + def is_rhs_concave(self): + """ + Returns True if linear overestimators do not need binaries. Otherwise, returns False. + + Returns + ------- + bool + """ + return False diff --git a/pyomo/contrib/coramin/relaxations/multivariate.py b/pyomo/contrib/coramin/relaxations/multivariate.py new file mode 100644 index 00000000000..3a2275fe6a6 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/multivariate.py @@ -0,0 +1,121 @@ +from pyomo.contrib.coramin.utils.coramin_enums import RelaxationSide, FunctionShape +from pyomo.contrib.coramin.relaxations.custom_block import declare_custom_block +from pyomo.contrib.coramin.relaxations.relaxations_base import BaseRelaxationData +from pyomo.core.expr.visitor import identify_variables +import math +import pyomo.environ as pe +from pyomo.contrib.coramin.relaxations._utils import _get_bnds_list + + +@declare_custom_block(name='MultivariateRelaxation') +class MultivariateRelaxationData(BaseRelaxationData): + def __init__(self, component): + super(MultivariateRelaxationData, self).__init__(component) + self._xs = None + self._aux_var = None + self._f_x_expr = None + self._function_shape = FunctionShape.UNKNOWN + + def get_rhs_vars(self): + return self._xs + + def get_rhs_expr(self): + return self._f_x_expr + + def vars_with_bounds_in_relaxation(self): + return list() + + def set_input( + self, + aux_var, + shape, + f_x_expr, + use_linear_relaxation=True, + large_coef=1e5, + small_coef=1e-10, + safety_tol=1e-10, + ): + """ + Parameters + ---------- + aux_var: pyomo.core.base.var._GeneralVarData + The auxiliary variable replacing f(x) + shape: FunctionShape + Either FunctionShape.CONVEX or FunctionShape.CONCAVE + f_x_expr: pyomo expression + The pyomo expression representing f(x) + use_linear_relaxation: bool + Specifies whether a linear or nonlinear relaxation should be used + """ + if shape not in {FunctionShape.CONVEX, FunctionShape.CONCAVE}: + raise ValueError( + 'MultivariateRelaxation only supports concave or convex functions.' + ) + self._function_shape = shape + if shape == FunctionShape.CONVEX: + relaxation_side = RelaxationSide.UNDER + else: + relaxation_side = RelaxationSide.OVER + super().set_input( + relaxation_side=relaxation_side, + use_linear_relaxation=use_linear_relaxation, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + ) + self._xs = tuple(identify_variables(f_x_expr, include_fixed=False)) + object.__setattr__(self, '_aux_var', aux_var) + self._f_x_expr = f_x_expr + + def build( + self, + aux_var, + shape, + f_x_expr, + use_linear_relaxation=True, + large_coef=1e5, + small_coef=1e-10, + safety_tol=1e-10, + ): + self.set_input( + aux_var=aux_var, + shape=shape, + f_x_expr=f_x_expr, + use_linear_relaxation=use_linear_relaxation, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + ) + self.rebuild() + + def is_rhs_convex(self): + return self._function_shape == FunctionShape.CONVEX + + def is_rhs_concave(self): + return self._function_shape == FunctionShape.CONCAVE + + @property + def use_linear_relaxation(self): + return self._use_linear_relaxation + + @use_linear_relaxation.setter + def use_linear_relaxation(self, value): + self._use_linear_relaxation = value + + @property + def relaxation_side(self): + return BaseRelaxationData.relaxation_side.fget(self) + + @relaxation_side.setter + def relaxation_side(self, val): + if self.is_rhs_convex(): + if val != RelaxationSide.UNDER: + raise ValueError( + 'MultivariateRelaxations only support underestimators for convex functions' + ) + if self.is_rhs_concave(): + if val != RelaxationSide.OVER: + raise ValueError( + 'MultivariateRelaxations only support overestimators for concave functions' + ) + BaseRelaxationData.relaxation_side.fset(self, val) diff --git a/pyomo/contrib/coramin/relaxations/relaxations_base.py b/pyomo/contrib/coramin/relaxations/relaxations_base.py new file mode 100644 index 00000000000..91f2db73761 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/relaxations_base.py @@ -0,0 +1,836 @@ +from pyomo.core.base.block import _BlockData, Block +from .custom_block import declare_custom_block +import weakref +import pyomo.environ as pe +from collections.abc import Iterable +from pyomo.common.collections import ComponentSet, ComponentMap +from pyomo.contrib.coramin.utils.coramin_enums import FunctionShape, RelaxationSide +import warnings +import logging +import math +from ._utils import _get_bnds_list, _get_bnds_tuple +import sys +from pyomo.core.expr import taylor_series_expansion +from typing import Sequence, Dict, Tuple, Optional, Union, Mapping, MutableMapping, List +from pyomo.core.base.param import IndexedParam, ScalarParam, _ParamData +from pyomo.core.base.var import _GeneralVarData +from pyomo.core.expr.calculus.diff_with_pyomo import reverse_sd +from pyomo.core.expr.numeric_expr import LinearExpression, ExpressionBase +from pyomo.core.base.constraint import ( + IndexedConstraint, + ScalarConstraint, + _GeneralConstraintData, +) +from pyomo.contrib.fbbt import interval + +pyo = pe +logger = logging.getLogger(__name__) + +""" +Base classes for relaxations +""" + + +class _OACut(object): + def __init__( + self, + nonlin_expr, + expr_vars: Sequence[_GeneralVarData], + coefficients: Sequence[_ParamData], + offset: _ParamData, + ): + self.expr_vars = expr_vars + self.nonlin_expr = nonlin_expr + self.coefficients = coefficients + self.offset = offset + derivs = reverse_sd(self.nonlin_expr) + self.derivs = [derivs[i] for i in self.expr_vars] + self.cut_expr = LinearExpression( + constant=self.offset, + linear_coefs=self.coefficients, + linear_vars=self.expr_vars, + ) + self.current_pt = None + + def update( + self, + var_vals: Sequence[float], + relaxation_side: RelaxationSide, + too_small: float, + too_large: float, + safety_tol: float, + ) -> Tuple[bool, Optional[_GeneralVarData], Optional[float], Optional[str]]: + res = (True, None, None, None) + self.current_pt = var_vals + orig_values = [i.value for i in self.expr_vars] + for v, val in zip(self.expr_vars, var_vals): + v.set_value(val, skip_validation=True) + try: + offset_val = pe.value(self.nonlin_expr, exception=False) + if offset_val is None: + res = (False, None, None, 'evaluation error') + else: + for ndx, v in enumerate(self.expr_vars): + der = pe.value(self.derivs[ndx], exception=False) + if der is None: + res = (False, None, None, 'evaluation error') + break + else: + offset_val -= der * v.value + self.coefficients[ndx]._value = der + if res[0]: + self.offset._value = offset_val + except (OverflowError, ValueError, ZeroDivisionError) as e: + res = (False, None, None, str(e)) + finally: + for v, val in zip(self.expr_vars, orig_values): + v.set_value(val, skip_validation=True) + if res[0]: + res = _check_cut( + self.cut_expr, + too_small=too_small, + too_large=too_large, + relaxation_side=relaxation_side, + safety_tol=safety_tol, + ) + return res + + def __repr__(self): + pt_str = {str(v): p for v, p in zip(self.expr_vars, self.current_pt)} + pt_str = str(pt_str) + s = f'OA Cut at {pt_str}' + return s + + def __str__(self): + return self.__repr__() + + +def _check_cut( + cut: LinearExpression, too_small, too_large, relaxation_side, safety_tol +): + res = (True, None, None, None) + for coef_p, v in zip(cut.linear_coefs, cut.linear_vars): + coef = coef_p.value + if type(coef) is complex or not math.isfinite(coef) or abs(coef) >= too_large: + res = (False, v, coef, None) + elif 0 < abs(coef) <= too_small and v.has_lb() and v.has_ub(): + coef_p._value = 0 + if relaxation_side == RelaxationSide.UNDER: + cut.constant._value = interval.add( + cut.constant.value, + cut.constant.value, + *interval.mul(v.lb, v.ub, coef, coef), + )[0] + elif relaxation_side == RelaxationSide.OVER: + cut.constant._value = interval.add( + cut.constant.value, + cut.constant.value, + *interval.mul(v.lb, v.ub, coef, coef), + )[1] + else: + raise ValueError('relaxation_side should be either UNDER or OVER') + if relaxation_side == RelaxationSide.UNDER: + cut.constant._value -= safety_tol + else: + cut.constant._value += safety_tol + if ( + type(cut.constant.value) is complex + or not math.isfinite(cut.constant.value) + or abs(cut.constant.value) >= too_large + ): + res = (False, None, cut.constant.value, None) + return res + + +@declare_custom_block(name='BaseRelaxation') +class BaseRelaxationData(_BlockData): + def __init__(self, component): + _BlockData.__init__(self, component) + self._relaxation_side = RelaxationSide.BOTH + self._use_linear_relaxation = True + self._large_coef = 1e5 + self._small_coef = 1e-10 + self._needs_rebuilt = True + self.safety_tol = 1e-10 + + self._oa_points: Dict[Tuple[float, ...], _OACut] = dict() + self._oa_param_indices: MutableMapping[_ParamData, int] = pe.ComponentMap() + self._current_param_index = 0 + self._oa_params: Optional[IndexedParam] = None + self._cuts: Optional[IndexedConstraint] = None + + self._saved_oa_points = list() + self._oa_stack_map = dict() + + self._original_constraint: Optional[ScalarConstraint] = None + self._nonlinear: Optional[ScalarConstraint] = None + + def set_input( + self, + relaxation_side=RelaxationSide.BOTH, + use_linear_relaxation=True, + large_coef=1e5, + small_coef=1e-10, + safety_tol=1e-10, + ): + self.relaxation_side = relaxation_side + self.use_linear_relaxation = use_linear_relaxation + self._large_coef = large_coef + self._small_coef = small_coef + self.safety_tol = safety_tol + self._needs_rebuilt = True + + self.clear_oa_points() + self._saved_oa_points = list() + self._oa_stack_map = dict() + + def get_aux_var(self) -> _GeneralVarData: + """ + All Coramin relaxations are relaxations of constraints of the form w <=/=/>= f(x). This method returns w + + Returns + ------- + aux_var: pyomo.core.base.var._GeneralVarData + The variable representing w in w = f(x) (which is the constraint being relaxed). + """ + return self._aux_var + + def get_rhs_vars(self) -> Tuple[_GeneralVarData, ...]: + raise NotImplementedError('This method should be implemented by subclasses') + + def get_rhs_expr(self) -> ExpressionBase: + raise NotImplementedError('This method should be implemented by subclasses') + + def _get_expr_for_oa(self): + return self.get_rhs_expr() + + @property + def small_coef(self): + return self._small_coef + + @small_coef.setter + def small_coef(self, val): + self._small_coef = val + + @property + def large_coef(self): + return self._large_coef + + @large_coef.setter + def large_coef(self, val): + self._large_coef = val + + @property + def use_linear_relaxation(self) -> bool: + """ + If this is True, the relaxation will use a linear relaxation. If False, then a nonlinear relaxation may be used. + Take x^2 for example, the underestimator can be quadratic. + + Returns + ------- + bool + """ + return self._use_linear_relaxation + + @use_linear_relaxation.setter + def use_linear_relaxation(self, val: bool): + if not val: + raise ValueError( + 'Relaxations of type {0} do not support relaxations that are not linear.'.format( + type(self) + ) + ) + + def remove_relaxation(self): + """ + Remove any auto-created vars/constraints from the relaxation block + """ + del self._cuts + self._cuts = None + del self._original_constraint + self._original_constraint = None + del self._nonlinear + self._nonlinear = None + + def _has_a_convex_side(self): + if self.has_convex_underestimator() and self.relaxation_side in { + RelaxationSide.UNDER, + RelaxationSide.BOTH, + }: + return True + if self.has_concave_overestimator() and self.relaxation_side in { + RelaxationSide.OVER, + RelaxationSide.BOTH, + }: + return True + return False + + def _check_valid_domain_for_relaxation(self) -> bool: + for v in self.get_rhs_vars(): + lb, ub = _get_bnds_tuple(v) + if not math.isfinite(lb) or not math.isfinite(ub): + return False + return True + + def rebuild(self, build_nonlinear_constraint=False, ensure_oa_at_vertices=True): + # we have to ensure only one of + # - self._cuts + # - self._nonlinear + # - self._original_constraint + # is ever not None at one time + needs_rebuilt = self._needs_rebuilt + if build_nonlinear_constraint: + if self._original_constraint is None: + needs_rebuilt = True + else: + if self.use_linear_relaxation: + if self._nonlinear is not None or self._original_constraint is not None: + needs_rebuilt = True + else: + if self._cuts is not None or self._original_constraint is not None: + needs_rebuilt = True + + if needs_rebuilt: + self.remove_relaxation() + + self._needs_rebuilt = False + + if build_nonlinear_constraint and self._original_constraint is None: + del self._original_constraint + if self.relaxation_side == RelaxationSide.BOTH: + self._original_constraint = pe.Constraint( + expr=self.get_aux_var() == self.get_rhs_expr() + ) + elif self.relaxation_side == RelaxationSide.UNDER: + self._original_constraint = pe.Constraint( + expr=self.get_aux_var() >= self.get_rhs_expr() + ) + else: + self._original_constraint = pe.Constraint( + expr=self.get_aux_var() <= self.get_rhs_expr() + ) + elif not build_nonlinear_constraint: + if self._has_a_convex_side(): + if self.use_linear_relaxation: + if self._cuts is None: + del self._cuts + self._cuts = IndexedConstraint(pe.Any) + if self._oa_params is None: + del self._oa_params + self._oa_params = IndexedParam( + pe.Any, mutable=True, initialize=0, within=pe.Any + ) + self.clean_oa_points(ensure_oa_at_vertices=ensure_oa_at_vertices) + self._update_oa_cuts() + else: + if self._nonlinear is None: + del self._nonlinear + if self.has_convex_underestimator(): + self._nonlinear = pe.Constraint( + expr=self.get_aux_var() + >= self._get_expr_for_oa() - self.safety_tol + ) + else: + assert self.has_concave_overestimator() + self._nonlinear = pe.Constraint( + expr=self.get_aux_var() + <= self._get_expr_for_oa() + self.safety_tol + ) + + def vars_with_bounds_in_relaxation(self): + """ + This method returns a list of variables whose bounds appear in the constraints defining the relaxation. + Take the McCormick relaxation of a bilinear term (w = x * y) for example. The McCormick relaxation is + + w >= xl * y + x * yl - xl * yl + w >= xu * y + x * yu - xu * yu + w <= xu * y + x * yl - xu * yl + w <= x * yu + xl * y - xl * yu + + where xl and xu are the lower and upper bounds for x, respectively, and yl and yu are the lower and upper + bounds for y, respectively. Because xl, xu, yl, and yu appear in the constraints, this method would return + + [x, y] + + As another example, take w >= x**2. A linear relaxation of this constraint just involves linear underestimators, + which do not depend on the bounds of x or w. Therefore, this method would return an empty list. + """ + raise NotImplementedError( + 'This method should be implemented in the derived class.' + ) + + def get_deviation(self): + """ + All Coramin relaxations are relaxations of constraints of the form w <=/=/>= f(x). This method returns + + max{f(x) - w, 0} if relaxation_side is RelaxationSide.UNDER + max{w - f(x), 0} if relaxation_side is RelaxationSide.OVER + abs(w - f(x)) if relaxation_side is RelaxationSide.BOTH + + Returns + ------- + float + """ + dev = pe.value(self.get_aux_var()) - pe.value(self.get_rhs_expr()) + if self.relaxation_side is RelaxationSide.BOTH: + dev = abs(dev) + elif self.relaxation_side is RelaxationSide.UNDER: + dev = max(-dev, 0) + else: + dev = max(dev, 0) + return dev + + def is_rhs_convex(self): + """ + All Coramin relaxations are relaxations of constraints of the form w <=/=/>= f(x). This method returns True if f(x) + is convex and False otherwise. + + Returns + ------- + bool + """ + raise NotImplementedError( + 'This method should be implemented in the derived class.' + ) + + def is_rhs_concave(self): + """ + All Coramin relaxations are relaxations of constraints of the form w <=/=/>= f(x). This method returns True if f(x) + is concave and False otherwise. + + Returns + ------- + bool + """ + raise NotImplementedError( + 'This method should be implemented in the derived class.' + ) + + def has_convex_underestimator(self): + return self.is_rhs_convex() + + def has_concave_overestimator(self): + return self.is_rhs_concave() + + @property + def relaxation_side(self): + return self._relaxation_side + + @relaxation_side.setter + def relaxation_side(self, val): + if val not in RelaxationSide: + raise ValueError('{0} is not a valid member of RelaxationSide'.format(val)) + if val != self._relaxation_side: + self._needs_rebuilt = True + self._relaxation_side = val + + def _get_pprint_string(self): + if self.relaxation_side == RelaxationSide.BOTH: + relational_operator_string = '==' + elif self.relaxation_side == RelaxationSide.UNDER: + relational_operator_string = '>=' + elif self.relaxation_side == RelaxationSide.OVER: + relational_operator_string = '<=' + else: + raise ValueError('Unexpected relaxation side') + return f'Relaxation for {self.get_aux_var()} {relational_operator_string} {self.get_rhs_expr()}' + + def pprint(self, ostream=None, verbose=False, prefix=""): + if ostream is None: + ostream = sys.stdout + + ostream.write( + '{0}{1}: {2}\n'.format(prefix, self.name, self._get_pprint_string()) + ) + + if verbose: + super(BaseRelaxationData, self).pprint( + ostream=ostream, verbose=verbose, prefix=(prefix + ' ') + ) + + def _get_oa_cut(self) -> _OACut: + rhs_vars = self.get_rhs_vars() + coef_params = list() + for v in rhs_vars: + p = self._oa_params[self._current_param_index] + p.value = None + self._oa_param_indices[p] = self._current_param_index + coef_params.append(p) + self._current_param_index += 1 + offset_param = self._oa_params[self._current_param_index] + offset_param.value = None + self._oa_param_indices[offset_param] = self._current_param_index + self._current_param_index += 1 + oa_cut = _OACut(self._get_expr_for_oa(), rhs_vars, coef_params, offset_param) + return oa_cut + + def _remove_oa_cut(self, oa_cut: _OACut): + for p in oa_cut.coefficients: + del self._oa_params[self._oa_param_indices[p]] + del self._oa_param_indices[p] + del self._oa_params[self._oa_param_indices[oa_cut.offset]] + del self._oa_param_indices[oa_cut.offset] + if ( + oa_cut in self._cuts + ): # if the cut did not pass _check_cut, it won't be in self._cuts + del self._cuts[oa_cut] + + def _log_bad_cut(self, fail_var, fail_coef, err_msg): + if fail_var is None and fail_coef is None: + logger.debug( + f'Encountered exception when adding OA cut ' + f'for "{self._get_pprint_string()}"; Error message: {err_msg}' + ) + elif fail_var is None: + logger.debug( + f'Skipped OA cut for "{self._get_pprint_string()}" due to a ' + f'large constant value: {fail_coef}' + ) + else: + logger.debug( + f'Skipped OA cut for "{self._get_pprint_string()}" due to a ' + f'small or large coefficient for {str(fail_var)}: {fail_coef}' + ) + + def _add_oa_cut( + self, pt_tuple: Tuple[float, ...], oa_cut: _OACut + ) -> Optional[_GeneralConstraintData]: + if self._nonlinear is not None or self._original_constraint is not None: + raise ValueError('Can only add an OA cut when using a linear relaxation') + if self.has_convex_underestimator(): + rel_side = RelaxationSide.UNDER + else: + assert self.has_concave_overestimator() + rel_side = RelaxationSide.OVER + cut_info = oa_cut.update( + var_vals=pt_tuple, + relaxation_side=rel_side, + too_small=self.small_coef, + too_large=self.large_coef, + safety_tol=self.safety_tol, + ) + success, fail_var, fail_coef, err_msg = cut_info + if not success: + self._log_bad_cut(fail_var, fail_coef, err_msg) + if oa_cut in self._cuts: + del self._cuts[oa_cut] + else: + if oa_cut not in self._cuts: + if self.has_convex_underestimator(): + self._cuts[oa_cut] = self.get_aux_var() >= oa_cut.cut_expr + else: + self._cuts[oa_cut] = self.get_aux_var() <= oa_cut.cut_expr + return self._cuts[oa_cut] + return None + + def _update_oa_cuts(self): + for pt_tuple, oa_cut in self._oa_points.items(): + self._add_oa_cut(pt_tuple, oa_cut) + + # remove any cuts that may have been added with add_cut(keep_cut=False) + all_oa_cuts = set(self._oa_points.values()) + for oa_cut in list(self._cuts): + if oa_cut not in all_oa_cuts: + self._remove_oa_cut(oa_cut) + + def _add_oa_point(self, pt_tuple: Tuple[float, ...]): + if pt_tuple not in self._oa_points: + self._oa_points[pt_tuple] = self._get_oa_cut() + + def add_oa_point( + self, + var_values: Optional[ + Union[Tuple[float, ...], Mapping[_GeneralVarData, float]] + ] = None, + ): + """ + Add a point at which an outer-approximation cut for a convex constraint should be added. This does not + rebuild the relaxation. You must call rebuild() for the constraint to get added. + + Parameters + ---------- + var_values: Optional[Union[Tuple[float, ...], Mapping[_GeneralVarData, float]]] + """ + if self._has_a_convex_side(): + if var_values is None: + var_values = tuple(v.value for v in self.get_rhs_vars()) + elif type(var_values) is tuple: + pass + else: + var_values = tuple(var_values[v] for v in self.get_rhs_vars()) + self._add_oa_point(var_values) + + def push_oa_points(self, key=None): + """ + Save the current list of OA points for later use through pop_oa_points(). + """ + to_save = [i for i in self._oa_points.keys()] + if key is not None: + self._oa_stack_map[key] = to_save + else: + self._saved_oa_points.append(to_save) + + def clear_oa_points(self): + """ + Delete any existing OA points. + """ + self._oa_points = dict() + self._oa_param_indices = pe.ComponentMap() + self._current_param_index = 0 + if self._oa_params is not None: + del self._oa_params + self._oa_params = pe.Param( + pe.Any, mutable=True, initialize=0, within=pe.Any + ) + if self._cuts is not None: + del self._cuts + self._cuts = pe.Constraint(pe.Any) + + def pop_oa_points(self, key=None): + """ + Use the most recently saved list of OA points + """ + self.clear_oa_points() + if key is None: + list_of_points = self._saved_oa_points.pop(-1) + else: + list_of_points = self._oa_stack_map.pop(key) + for pt_tuple in list_of_points: + self._add_oa_point(pt_tuple) + + def add_cut( + self, keep_cut=True, check_violation=True, feasibility_tol=1e-8 + ) -> Optional[_GeneralConstraintData]: + """ + This function will add a linear cut to the relaxation. Cuts are only generated for the convex side of the + constraint (if the constraint has a convex side). For example, if the relaxation is a PWXSquaredRelaxationData + for y = x**2, the add_cut will add an underestimator at x.value (but only if y.value < x.value**2). If + relaxation is a PWXSquaredRelaxationData for y < x**2, then no cut will be added. If relaxation is is a + PWMcCormickRelaxationData, then no cut will be added. + + Parameters + ---------- + keep_cut: bool + If keep_cut is True, then add_oa_point will also be called. Be careful if the relaxation object is relaxing + the nonconvex side of the constraint. Thus, the cut will be reconstructed when rebuild is called. If + keep_cut is False, then the cut will be discarded when rebuild is called. + check_violation: bool + If True, then a cut is only added if the cut generated would cut off the current point (current values + of the variables) by more than feasibility_tol. + feasibility_tol: float + Only used if check_violation is True + + Returns + ------- + new_con: pyomo.core.base.constraint._GeneralConstraintData + """ + rhs_vars = self.get_rhs_vars() + var_vals = tuple(v.value for v in rhs_vars) + + if var_vals in self._oa_points: + return None + + new_con = None + if self._has_a_convex_side(): + if check_violation: + needs_cut = False + try: + rhs_val = pe.value(self._get_expr_for_oa()) + except (OverflowError, ZeroDivisionError, ValueError): + rhs_val = None + if rhs_val is not None: + if self.has_convex_underestimator(): + viol = rhs_val - pe.value(self.get_aux_var()) + else: + viol = pe.value(self.get_aux_var()) - rhs_val + if viol > feasibility_tol: + needs_cut = True + else: + needs_cut = True + if needs_cut: + oa_cut = self._get_oa_cut() + new_con = self._add_oa_cut(pt_tuple=var_vals, oa_cut=oa_cut) + if keep_cut: + self._oa_points[var_vals] = oa_cut + + return new_con + + def clean_oa_points(self, ensure_oa_at_vertices=True): + if not self._has_a_convex_side(): + return + + rhs_vars = self.get_rhs_vars() + bnds_list: List[Tuple[float, float]] = list() + for v in rhs_vars: + bnds_list.append(_get_bnds_tuple(v)) + + for pt_tuple, oa_cut in list(self._oa_points.items()): + new_pt_list = list() + for (v_lb, v_ub), pt in zip(bnds_list, pt_tuple): + if pt < v_lb: + new_pt_list.append(v_lb) + elif pt > v_ub: + new_pt_list.append(v_ub) + else: + new_pt_list.append(pt) + new_pt_tuple = tuple(new_pt_list) + del self._oa_points[pt_tuple] + if new_pt_tuple in self._oa_points: + self._remove_oa_cut(oa_cut) + else: + self._oa_points[new_pt_tuple] = oa_cut + if ensure_oa_at_vertices: + lb_list = list() + ub_list = list() + for lb, ub in bnds_list: + if math.isfinite(lb) and math.isfinite(ub): + lb_list.append(lb) + ub_list.append(ub) + elif math.isfinite(lb): + lb_list.append(lb) + ub_list.append(max(lb + 1, 1)) + elif math.isfinite(ub): + lb_list.append(min(ub - 1, -1)) + ub_list.append(ub) + else: + lb_list.append(-1) + ub_list.append(1) + lb_tuple = tuple(lb_list) + ub_tuple = tuple(ub_list) + if lb_tuple not in self._oa_points: + if len(self._oa_points) <= 1: + self._add_oa_point(lb_tuple) + else: # move the smallest point to lb_tuple + min_pt = min(self._oa_points.keys()) + min_oa_cut = self._oa_points[min_pt] + del self._oa_points[min_pt] + self._oa_points[lb_tuple] = min_oa_cut + if ub_tuple not in self._oa_points: + if len(self._oa_points) <= 1: + self._add_oa_point(ub_tuple) + else: # move the largest point to ub_tuple + max_pt = max(self._oa_points.keys()) + max_oa_cut = self._oa_points[max_pt] + del self._oa_points[max_pt] + self._oa_points[ub_tuple] = max_oa_cut + + +@declare_custom_block(name='BasePWRelaxation') +class BasePWRelaxationData(BaseRelaxationData): + def __init__(self, component): + BaseRelaxationData.__init__(self, component) + + self._partitions = ComponentMap() # ComponentMap: var: list of float + self._saved_partitions = list() # list of ComponentMap + + def rebuild(self, build_nonlinear_constraint=False, ensure_oa_at_vertices=True): + """ + Remove any auto-created vars/constraints from the relaxation block and recreate it + """ + super(BasePWRelaxationData, self).rebuild( + build_nonlinear_constraint=build_nonlinear_constraint, + ensure_oa_at_vertices=ensure_oa_at_vertices, + ) + self.clean_partitions() + + def set_input( + self, + relaxation_side=RelaxationSide.BOTH, + use_linear_relaxation=True, + large_coef=1e5, + small_coef=1e-10, + safety_tol=1e-10, + ): + super(BasePWRelaxationData, self).set_input( + relaxation_side=relaxation_side, + use_linear_relaxation=use_linear_relaxation, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + ) + self._partitions = ComponentMap() + self._saved_partitions = list() + + def add_partition_point(self): + """ + Add a point to the current partitioning. This does not rebuild the relaxation. You must call rebuild() + to rebuild the relaxation. + """ + raise NotImplementedError( + 'This method should be implemented in the derived class.' + ) + + def _add_partition_point(self, var, value=None): + if value is None: + value = pe.value(var) + # if the point is outside the variable's bounds, then it will simply get removed when clean_partitions + # gets called. + self._partitions[var].append(value) + + def push_partitions(self): + """ + Save the current partitioning for later use through pop_partitions(). + """ + self._saved_partitions.append( + pe.ComponentMap((k, list(v)) for k, v in self._partitions.items()) + ) + + def clear_partitions(self): + """ + Delete any existing partitioning scheme. + """ + tmp = ComponentMap() + for var, pts in self._partitions.items(): + tmp[var] = [pe.value(var.lb), pe.value(var.ub)] + self._partitions = tmp + + def pop_partitions(self): + """ + Use the most recently saved partitioning. + """ + self._partitions = self._saved_partitions.pop(-1) + + def clean_partitions(self): + # discard any points in the partitioning that are not within the variable bounds + for var, pts in list(self._partitions.items()): + pts = list(set(pts)) + pts.sort() + self._partitions[var] = pts + + for var, pts in self._partitions.items(): + lb, ub = tuple(_get_bnds_list(var)) + + new_pts = list() + new_pts.append(lb) + for val in pts[1:-1]: + if lb < val < ub: + new_pts.append(val) + new_pts.append(ub) + self._partitions[var] = new_pts + + def get_active_partitions(self): + ans = ComponentMap() + for var, pts in self._partitions.items(): + val = pyo.value(var) + lower = None + upper = None + if not (pts[0] - 1e-6 <= val <= pts[-1] + 1e-6): + raise ValueError( + 'The variable value must be within the variable bounds' + ) + if val < pts[0]: + lower = pts[0] + upper = pts[1] + elif val > pts[-1]: + lower = pts[-2] + upper = pts[-1] + else: + for p1, p2 in zip(pts[0:-1], pts[1:]): + if p1 <= val <= p2: + lower = p1 + upper = p2 + break + assert lower is not None + assert upper is not None + ans[var] = lower, upper + return ans diff --git a/pyomo/contrib/coramin/relaxations/segments.py b/pyomo/contrib/coramin/relaxations/segments.py new file mode 100644 index 00000000000..8566df30434 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/segments.py @@ -0,0 +1,24 @@ +import pyomo.environ as pyo +import warnings +import logging + +logger = logging.getLogger(__name__) + + +def compute_k_segment_points(v, k): + """ + Return a list of points that generates k segments between v.lb and v.ub + + Parameters + ---------- + v: pyo.Var + k: int + + Returns + ------- + pts: list of float + """ + delta = (pyo.value(v.ub) - pyo.value(v.lb)) / k + pts = [pyo.value(v.lb) + i * delta for i in range(k)] + pts.append(pyo.value(v.ub)) + return pts diff --git a/pyomo/contrib/coramin/relaxations/split_expr.py b/pyomo/contrib/coramin/relaxations/split_expr.py new file mode 100644 index 00000000000..c879750aea9 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/split_expr.py @@ -0,0 +1,177 @@ +from pyomo.core.expr import numeric_expr +from pyomo.core.expr.visitor import identify_variables, ExpressionValueVisitor +from pyomo.core.expr.numvalue import ( + nonpyomo_leaf_types, + NumericValue, + is_potentially_variable, +) +from typing import MutableMapping, Tuple, Sequence, Union, List + + +def _flatten_expr_ProductExpression( + node: numeric_expr.ProductExpression, + values: Union[Tuple[NumericValue, ...], List[NumericValue]], +): + arg1, arg2 = values + arg1_type = type(arg1) + arg2_type = type(arg2) + if is_potentially_variable(arg1) and is_potentially_variable(arg2): + res = numeric_expr.ProductExpression(values) + elif is_potentially_variable(arg1): + if arg1_type is numeric_expr.SumExpression: + res = numeric_expr.SumExpression([arg2 * i for i in arg1.args]) + elif arg1_type is numeric_expr.LinearExpression: + res = numeric_expr.LinearExpression( + constant=arg2 * arg1.constant, + linear_coefs=[arg2 * i for i in arg1.linear_coefs], + linear_vars=list(arg1.linear_vars), + ) + else: + res = numeric_expr.ProductExpression(values) + elif is_potentially_variable(arg2): + if arg2_type is numeric_expr.SumExpression: + res = numeric_expr.SumExpression([arg1 * i for i in arg2.args]) + elif arg2_type is numeric_expr.LinearExpression: + res = numeric_expr.LinearExpression( + constant=arg1 * arg2.constant, + linear_coefs=[arg1 * i for i in arg2.linear_coefs], + linear_vars=list(arg2.linear_vars), + ) + else: + res = numeric_expr.ProductExpression(values) + else: + res = numeric_expr.ProductExpression(values) + return res + + +def _flatten_expr_SumExpression( + node: numeric_expr.SumExpression, + values: Union[Tuple[NumericValue, ...], List[NumericValue]], +): + all_args = list() + for arg in values: + if isinstance(arg, numeric_expr.SumExpression): + all_args.extend(arg.args) + elif isinstance(arg, numeric_expr.LinearExpression): + for c, v in zip(arg.linear_vars, arg.linear_coefs): + all_args.append(numeric_expr.MonomialTermExpression((c, v))) + all_args.append(arg.constant) + else: + all_args.append(arg) + return numeric_expr.SumExpression(all_args) + + +def _flatten_expr_NegationExpression( + node: numeric_expr.NegationExpression, + values: Union[Tuple[NumericValue, ...], List[NumericValue]], +): + assert len(values) == 1 + arg = values[0] + if isinstance(arg, numeric_expr.SumExpression): + res = numeric_expr.SumExpression([-i for i in arg.args]) + elif isinstance(arg, numeric_expr.LinearExpression): + new_args = [ + numeric_expr.MonomialTermExpression((-c, v)) + for c, v in zip(arg.linear_vars, arg.linear_coefs) + ] + new_args.append(-arg.constant) + res = numeric_expr.SumExpression(new_args) + else: + res = numeric_expr.NegationExpression((arg,)) + return res + + +def _flatten_expr_default( + node: numeric_expr.ExpressionBase, + values: Union[Tuple[NumericValue, ...], List[NumericValue]], +): + return node.create_node_with_local_data(tuple(values)) + + +_flatten_expr_map = dict() +_flatten_expr_map[numeric_expr.SumExpression] = _flatten_expr_SumExpression +_flatten_expr_map[numeric_expr.NegationExpression] = _flatten_expr_NegationExpression +_flatten_expr_map[numeric_expr.ProductExpression] = _flatten_expr_ProductExpression + + +class FlattenExprVisitor(ExpressionValueVisitor): + def visit(self, node, values): + node_type = type(node) + if node_type in _flatten_expr_map: + return _flatten_expr_map[node_type](node, values) + else: + return _flatten_expr_default(node, values) + + def visiting_potential_leaf(self, node): + node_type = type(node) + if node_type in nonpyomo_leaf_types: + return True, node + elif not node.is_expression_type(): + return True, node + elif node_type is numeric_expr.LinearExpression: + return True, node + else: + return False, None + + +def flatten_expr(expr): + visitor = FlattenExprVisitor() + return visitor.dfs_postorder_stack(expr) + + +class Grouper(object): + def __init__(self): + self._terms_by_num_var: MutableMapping[ + int, MutableMapping[Tuple[int, ...], NumericValue] + ] = dict() + + def add_term(self, expr): + vlist = list(identify_variables(expr=expr, include_fixed=False)) + vlist.sort(key=lambda x: id(x)) + v_ids = tuple(id(v) for v in vlist) + num_vars = len(vlist) + if num_vars not in self._terms_by_num_var: + self._terms_by_num_var[num_vars] = dict() + if v_ids not in self._terms_by_num_var[num_vars]: + self._terms_by_num_var[num_vars][v_ids] = expr + else: + self._terms_by_num_var[num_vars][v_ids] += expr + + def group(self) -> Sequence[NumericValue]: + num_var_list = list(self._terms_by_num_var.keys()) + num_var_list.sort(reverse=True) + for num_vars in num_var_list[1:]: + for last_num_vars in num_var_list: + if last_num_vars == num_vars: + break + for v_ids in list(self._terms_by_num_var[num_vars].keys()): + v_id_set = set(v_ids) + for last_v_ids in list( + self._terms_by_num_var[last_num_vars].keys() + ): + last_v_id_set = set(last_v_ids) + if len(v_id_set - last_v_id_set) == 0: + self._terms_by_num_var[last_num_vars][ + last_v_ids + ] += self._terms_by_num_var[num_vars][v_ids] + del self._terms_by_num_var[num_vars][v_ids] + break + + expr_list = list() + for num_vars in reversed(num_var_list): + for e in self._terms_by_num_var[num_vars].values(): + expr_list.append(e) + + return expr_list + + +def split_expr(expr): + expr = flatten_expr(expr) + if type(expr) is numeric_expr.SumExpression: + grouper = Grouper() + for arg in expr.args: + grouper.add_term(arg) + res = grouper.group() + else: + res = [expr] + return res diff --git a/pyomo/contrib/coramin/relaxations/tests/__init__.py b/pyomo/contrib/coramin/relaxations/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/relaxations/tests/test_alphabb.py b/pyomo/contrib/coramin/relaxations/tests/test_alphabb.py new file mode 100644 index 00000000000..10526157b3a --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/tests/test_alphabb.py @@ -0,0 +1,64 @@ +from pyomo.common import unittest +import pyomo.environ as pe +from pyomo.contrib import coramin +from pyomo.contrib.coramin.relaxations.alphabb import AlphaBBRelaxation + + +ipopt_available = pe.SolverFactory('ipopt').available() +gurobi_available = pe.SolverFactory('appsi_gurobi').available() + + +class TestAlphaBBRelaxation(unittest.TestCase): + @classmethod + def setUpClass(cls): + model = pe.ConcreteModel() + cls.model = model + model.x = pe.Var(bounds=(-2, 1)) + model.y = pe.Var(bounds=(-1, 1)) + model.w = pe.Var() + + model.f_x = pe.cos(model.x) * pe.sin(model.y) - model.x / (model.y**2 + 1) + + model.obj = pe.Objective(expr=model.w) + model.abb = AlphaBBRelaxation() + model.abb.build( + aux_var=model.w, + f_x_expr=model.f_x, + relaxation_side=coramin.RelaxationSide.UNDER, + eigenvalue_bounder=coramin.EigenValueBounder.GershgorinWithSimplification, + ) + + @unittest.skipUnless(ipopt_available, 'ipopt is not available') + def test_nonlinear(self): + model = self.model.clone() + model.abb.use_linear_relaxation = False + model.abb.rebuild() + + model.w.value = 0.0 + + for x_v in [model.x.lb, model.x.ub]: + for y_v in [model.y.lb, model.y.ub]: + model.x.value = x_v + model.y.value = y_v + f_x_v = pe.value(model.f_x) + abb_v = pe.value(model.abb._nonlinear.body) + self.assertAlmostEqual(f_x_v, abb_v) + + solver = pe.SolverFactory('ipopt') + solver.solve(model) + self.assertLessEqual(model.w.value, pe.value(model.f_x)) + + @unittest.skipUnless(gurobi_available, 'gurboi is not available') + def test_linear(self): + model = self.model.clone() + model.abb.use_linear_relaxation = True + + model.x.value = 0.0 + model.y.value = 0.0 + + for _ in range(5): + model.abb.add_oa_point() + model.abb.rebuild() + solver = pe.SolverFactory('appsi_gurobi') + solver.solve(model) + self.assertLessEqual(model.w.value, pe.value(model.f_x)) diff --git a/pyomo/contrib/coramin/relaxations/tests/test_auto_relax.py b/pyomo/contrib/coramin/relaxations/tests/test_auto_relax.py new file mode 100644 index 00000000000..e6fd54ddd16 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/tests/test_auto_relax.py @@ -0,0 +1,1181 @@ +import pyomo.environ as pe +from pyomo.contrib import coramin +from pyomo.common import unittest +from pyomo.core.expr.calculus.diff_with_pyomo import reverse_sd +from pyomo.core.expr.visitor import identify_variables, identify_components +import math +from pyomo.common.collections import ComponentSet +from pyomo.common.dependencies import numpy as np +from pyomo.core.base.param import _ParamData, ScalarParam +from pyomo.core.expr.sympy_tools import sympyify_expression +from pyomo.contrib import appsi +from pyomo.contrib.coramin.utils import RelaxationSide, Effort, EigenValueBounder +from pyomo.core.expr.compare import assertExpressionsEqual + + +gurobi_available = appsi.solvers.Gurobi().available() + + +class TestAutoRelax(unittest.TestCase): + def test_product1(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var(bounds=(-1, 1)) + m.z = pe.Var() + m.c = pe.Constraint(expr=m.z - m.x * m.y == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 1) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, -1) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], -1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWMcCormickRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertIn(m.y, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + + def test_product2(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var(bounds=(-1, 1)) + m.z = pe.Var() + m.v = pe.Var() + m.c1 = pe.Constraint(expr=m.z - m.x * m.y == 0) + m.c2 = pe.Constraint(expr=m.v - 3 * m.x * m.y == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, -1) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], -1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 2) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.v], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWMcCormickRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertIn(m.y, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + + def test_product3(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var(bounds=(-1, 1)) + m.z = pe.Var() + m.c = pe.Constraint(expr=m.z - m.x * m.y * 3 == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 1) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, -1) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWMcCormickRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertIn(m.y, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + relaxations = list(coramin.relaxations.relaxation_data_objects(rel)) + self.assertEqual(len(relaxations), 1) + + def test_product4(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.z = pe.Var() + m.c = pe.Constraint(expr=m.z - m.x * m.x == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 1) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, 0) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], -1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance( + rel.relaxations.rel0, coramin.relaxations.PWXSquaredRelaxationData + ) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(len(rel.relaxations.rel0.get_rhs_vars()), 1) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + relaxations = list(coramin.relaxations.relaxation_data_objects(rel)) + self.assertEqual(len(relaxations), 1) + + def test_quadratic(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.z = pe.Var() + m.w = pe.Var() + m.c = pe.Constraint(expr=m.x**2 + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**2 == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, 0) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], 1) + self.assertEqual(ders[m.y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWXSquaredRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + self.assertFalse(hasattr(rel.relaxations, 'rel1')) + + def test_cubic_convex(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(1, 2)) + m.y = pe.Var() + m.z = pe.Var() + m.w = pe.Var() + m.c = pe.Constraint(expr=m.x**3 + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**3 == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, 1) + self.assertAlmostEqual(rel.aux_vars[1].ub, 8) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], 1) + self.assertEqual(ders[m.y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWUnivariateRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + self.assertTrue(rel.relaxations.rel0.is_rhs_convex()) + self.assertFalse(rel.relaxations.rel0.is_rhs_concave()) + self.assertFalse(hasattr(rel.relaxations, 'rel1')) + + def test_cubic_concave(self): + m = pe.ConcreteModel() + m.x = x = pe.Var(bounds=(-2, -1)) + m.y = y = pe.Var() + m.z = z = pe.Var() + m.w = w = pe.Var() + m.c = pe.Constraint(expr=m.x**3 + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**3 == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, -8) + self.assertAlmostEqual(rel.aux_vars[1].ub, -1) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], 1) + self.assertEqual(ders[m.y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWUnivariateRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + self.assertFalse(rel.relaxations.rel0.is_rhs_convex()) + self.assertTrue(rel.relaxations.rel0.is_rhs_concave()) + self.assertFalse(hasattr(rel.relaxations, 'rel1')) + + def test_cubic(self): + m = pe.ConcreteModel() + m.x = x = pe.Var(bounds=(-1, 1)) + m.y = y = pe.Var() + m.z = z = pe.Var() + m.w = w = pe.Var() + m.c = pe.Constraint(expr=m.x**3 + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**3 == 0) + + rel = coramin.relaxations.relax(m) + + # this problem should turn into + # + # aux2 + y + z = 0 => aux_con[1] + # w - 3*aux2 = 0 => aux_con[2] + # aux1 = x**2 => rel0 + # aux2 = x*aux1 => rel1 + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 2) + + self.assertAlmostEqual(rel.aux_vars[1].lb, 0) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + + self.assertAlmostEqual(rel.aux_vars[2].lb, -1) + self.assertAlmostEqual(rel.aux_vars[2].ub, 1) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[z], 1) + self.assertEqual(ders[rel.aux_vars[2]], 1) + self.assertEqual(ders[y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[w], 1) + self.assertEqual(ders[rel.aux_vars[2]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWXSquaredRelaxation) + ) + self.assertIn(x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + + self.assertTrue(hasattr(rel.relaxations, 'rel1')) + self.assertTrue( + isinstance(rel.relaxations.rel1, coramin.relaxations.PWMcCormickRelaxation) + ) + self.assertIn(x, ComponentSet(rel.relaxations.rel1.get_rhs_vars())) + self.assertIn( + rel.aux_vars[1], ComponentSet(rel.relaxations.rel1.get_rhs_vars()) + ) + self.assertEqual(id(rel.aux_vars[2]), id(rel.relaxations.rel1.get_aux_var())) + + def test_pow_fractional1(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.z = pe.Var() + m.w = pe.Var() + m.p = pe.Param(initialize=0.5) + m.c = pe.Constraint(expr=m.x**m.p + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**m.p == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, 0) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], 1) + self.assertEqual(ders[m.y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWUnivariateRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + self.assertFalse(rel.relaxations.rel0.is_rhs_convex()) + self.assertTrue(rel.relaxations.rel0.is_rhs_concave()) + self.assertFalse(hasattr(rel.relaxations, 'rel1')) + + def test_pow_fractional2(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.z = pe.Var() + m.w = pe.Var() + m.p = pe.Param(initialize=1.5) + m.c = pe.Constraint(expr=m.x**m.p + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**m.p == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, 0) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], 1) + self.assertEqual(ders[m.y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWUnivariateRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + self.assertTrue(rel.relaxations.rel0.is_rhs_convex()) + self.assertFalse(rel.relaxations.rel0.is_rhs_concave()) + self.assertFalse(hasattr(rel.relaxations, 'rel1')) + + def test_pow_neg_even1(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(1, 2)) + m.y = pe.Var() + m.z = pe.Var() + m.w = pe.Var() + m.p = pe.Param(initialize=-2) + m.c = pe.Constraint(expr=m.x**m.p + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**m.p == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, 0.25) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], 1) + self.assertEqual(ders[m.y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWUnivariateRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + self.assertTrue(rel.relaxations.rel0.is_rhs_convex()) + self.assertFalse(rel.relaxations.rel0.is_rhs_concave()) + self.assertFalse(hasattr(rel.relaxations, 'rel1')) + + def test_pow_neg_even2(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, -1)) + m.y = pe.Var() + m.z = pe.Var() + m.w = pe.Var() + m.p = pe.Param(initialize=-2) + m.c = pe.Constraint(expr=m.x**m.p + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**m.p == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, 0.25) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], 1) + self.assertEqual(ders[m.y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWUnivariateRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + self.assertTrue(rel.relaxations.rel0.is_rhs_convex()) + self.assertFalse(rel.relaxations.rel0.is_rhs_concave()) + self.assertFalse(hasattr(rel.relaxations, 'rel1')) + + def test_pow_neg_odd1(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(1, 2)) + m.y = pe.Var() + m.z = pe.Var() + m.w = pe.Var() + m.p = pe.Param(initialize=-3) + m.c = pe.Constraint(expr=m.x**m.p + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**m.p == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, 0.125) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], 1) + self.assertEqual(ders[m.y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWUnivariateRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + self.assertTrue(rel.relaxations.rel0.is_rhs_convex()) + self.assertFalse(rel.relaxations.rel0.is_rhs_concave()) + self.assertFalse(hasattr(rel.relaxations, 'rel1')) + + def test_pow_neg_odd2(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, -1)) + m.y = pe.Var() + m.z = pe.Var() + m.w = pe.Var() + m.p = pe.Param(initialize=-3) + m.c = pe.Constraint(expr=m.x**m.p + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**m.p == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, -1) + self.assertAlmostEqual(rel.aux_vars[1].ub, -0.125) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], 1) + self.assertEqual(ders[m.y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[1]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWUnivariateRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + self.assertFalse(rel.relaxations.rel0.is_rhs_convex()) + self.assertTrue(rel.relaxations.rel0.is_rhs_concave()) + self.assertFalse(hasattr(rel.relaxations, 'rel1')) + + def test_pow_neg(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.z = pe.Var() + m.w = pe.Var() + m.p = pe.Param(initialize=-2) + m.c = pe.Constraint(expr=m.x**m.p + m.y + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * m.x**m.p == 0) + + rel = coramin.relaxations.relax(m) + + # This model should be relaxed to + # + # aux2 + y + z = 0 + # w - 3 * aux2 = 0 + # aux1 = x**2 + # aux1*aux2 = aux3 + # aux3 = 1 + # + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 3) + + self.assertAlmostEqual(rel.aux_vars[1].lb, 0) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + + self.assertTrue(rel.aux_vars[3].is_fixed()) + self.assertEqual(rel.aux_vars[3].value, 1) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[2]], 1) + self.assertEqual(ders[m.y], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 3) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[2]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWXSquaredRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + self.assertTrue(rel.relaxations.rel0.is_rhs_convex()) + self.assertFalse(rel.relaxations.rel0.is_rhs_concave()) + + self.assertTrue(hasattr(rel.relaxations, 'rel1')) + self.assertTrue( + isinstance(rel.relaxations.rel1, coramin.relaxations.PWMcCormickRelaxation) + ) + self.assertIn( + rel.aux_vars[1], ComponentSet(rel.relaxations.rel1.get_rhs_vars()) + ) + self.assertIn( + rel.aux_vars[2], ComponentSet(rel.relaxations.rel1.get_rhs_vars()) + ) + self.assertEqual(id(rel.aux_vars[3]), id(rel.relaxations.rel1.get_aux_var())) + + self.assertFalse(hasattr(rel.relaxations, 'rel2')) + + def test_sqrt(self): + m = pe.ConcreteModel() + m.x = pe.Var() + m.z = pe.Var() + m.c = pe.Constraint(expr=m.z + pe.sqrt(2 * pe.log(m.x)) <= 1) + orig = m + m = coramin.relaxations.relax(m) + rels = list( + coramin.relaxations.relaxation_data_objects( + m, descend_into=True, active=True, sort=True + ) + ) + self.assertEqual(len(rels), 2) + rel0 = m.relaxations.rel0 # log + rel1 = m.relaxations.rel1 # sqrt + self.assertEqual( + sympyify_expression(rel0.get_rhs_expr() - pe.log(orig.x))[1], 0 + ) + self.assertEqual( + sympyify_expression(rel1.get_rhs_expr() - m.aux_vars[3] ** 0.5)[1], 0 + ) + self.assertEqual( + sympyify_expression( + m.linear.cons[1].body - m.aux_vars[3] + 2 * m.aux_vars[1] + )[1], + 0, + ) + self.assertEqual( + sympyify_expression(m.linear.cons[2].body - orig.z - m.aux_vars[2])[1], 0 + ) + self.assertEqual(m.linear.cons[1].lower, 0) + self.assertEqual(m.linear.cons[2].lower, None) + self.assertEqual(m.linear.cons[2].upper, 1) + self.assertIs(rel0.get_aux_var(), m.aux_vars[1]) + self.assertIs(rel1.get_aux_var(), m.aux_vars[2]) + self.assertEqual(rel0.relaxation_side, coramin.utils.RelaxationSide.UNDER) + self.assertEqual(rel1.relaxation_side, coramin.utils.RelaxationSide.UNDER) + + def test_exp(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var(bounds=(-1, 1)) + m.z = pe.Var() + m.w = pe.Var() + m.c = pe.Constraint(expr=pe.exp(m.x * m.y) + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * pe.exp(m.x * m.y) == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 2) + + self.assertAlmostEqual(rel.aux_vars[1].lb, -1) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + + self.assertAlmostEqual(rel.aux_vars[2].lb, math.exp(-1)) + self.assertAlmostEqual(rel.aux_vars[2].ub, math.exp(1)) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[2]], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 2) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[2]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWMcCormickRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertIn(m.y, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + + self.assertTrue(hasattr(rel.relaxations, 'rel1')) + self.assertTrue( + isinstance(rel.relaxations.rel1, coramin.relaxations.PWUnivariateRelaxation) + ) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel1._x)) + self.assertEqual(id(rel.aux_vars[2]), id(rel.relaxations.rel1.get_aux_var())) + self.assertTrue(rel.relaxations.rel1.is_rhs_convex()) + self.assertFalse(rel.relaxations.rel1.is_rhs_concave()) + + self.assertFalse(hasattr(rel.relaxations, 'rel2')) + + def test_log(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(1, 2)) + m.y = pe.Var(bounds=(1, 2)) + m.z = pe.Var() + m.w = pe.Var() + m.c = pe.Constraint(expr=pe.log(m.x * m.y) + m.z == 0) + m.c2 = pe.Constraint(expr=m.w - 3 * pe.log(m.x * m.y) == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 2) + self.assertEqual(len(rel.aux_vars), 2) + + self.assertAlmostEqual(rel.aux_vars[1].lb, 1) + self.assertAlmostEqual(rel.aux_vars[1].ub, 4) + + self.assertAlmostEqual(rel.aux_vars[2].lb, math.log(1)) + self.assertAlmostEqual(rel.aux_vars[2].ub, math.log(4)) + + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[2]], 1) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 2) + + self.assertEqual(rel.linear.cons[2].lower, 0) + self.assertEqual(rel.linear.cons[2].upper, 0) + ders = reverse_sd(rel.linear.cons[2].body) + self.assertEqual(ders[m.w], 1) + self.assertEqual(ders[rel.aux_vars[2]], -3) + self.assertEqual(len(list(identify_variables(rel.linear.cons[2].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWMcCormickRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertIn(m.y, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + + self.assertTrue(hasattr(rel.relaxations, 'rel1')) + self.assertTrue( + isinstance(rel.relaxations.rel1, coramin.relaxations.PWUnivariateRelaxation) + ) + self.assertIn( + rel.aux_vars[1], ComponentSet(rel.relaxations.rel1.get_rhs_vars()) + ) + self.assertEqual(id(rel.aux_vars[2]), id(rel.relaxations.rel1.get_aux_var())) + self.assertFalse(rel.relaxations.rel1.is_rhs_convex()) + self.assertTrue(rel.relaxations.rel1.is_rhs_concave()) + + self.assertFalse(hasattr(rel.relaxations, 'rel2')) + + def test_div1(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var(bounds=(1, 2)) + m.z = pe.Var() + m.c = pe.Constraint(expr=m.z - m.x / m.y == 0) + rel = coramin.relaxations.relax(m) + relaxations = list(coramin.relaxations.relaxation_data_objects(rel)) + constraints = list( + coramin.relaxations.nonrelaxation_component_data_objects( + rel, ctype=pe.Constraint + ) + ) + self.assertEqual(len(relaxations), 1) + self.assertEqual(len(constraints), 1) + r = relaxations[0] + c = constraints[0] + self.assertIsInstance(r, coramin.relaxations.PWMcCormickRelaxationData) + c_vars = ComponentSet(identify_variables(c.body)) + self.assertEqual(len(c_vars), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertIn(rel.aux_vars[1], c_vars) + self.assertIn(m.z, c_vars) + r_vars = ComponentSet(r.get_rhs_vars()) + self.assertIn(m.y, r_vars) + self.assertIn(rel.aux_vars[1], r_vars) + self.assertIs(r.get_aux_var(), m.x) + + def test_div2(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var(bounds=(-1, 1)) + m.z = pe.Var() + m.c = pe.Constraint(expr=m.z - m.x * m.y / 2 == 0) + + rel = coramin.relaxations.relax(m) + + self.assertTrue(hasattr(rel, 'aux_vars')) + self.assertEqual(len(rel.linear.cons), 1) + self.assertEqual(len(rel.aux_vars), 1) + self.assertAlmostEqual(rel.aux_vars[1].lb, -1) + self.assertAlmostEqual(rel.aux_vars[1].ub, 1) + self.assertEqual(rel.linear.cons[1].lower, 0) + self.assertEqual(rel.linear.cons[1].upper, 0) + ders = reverse_sd(rel.linear.cons[1].body) + self.assertEqual(ders[m.z], 1) + self.assertEqual(ders[rel.aux_vars[1]], -0.5) + self.assertEqual(len(list(identify_variables(rel.linear.cons[1].body))), 2) + + self.assertTrue(hasattr(rel, 'relaxations')) + self.assertTrue(hasattr(rel.relaxations, 'rel0')) + self.assertTrue( + isinstance(rel.relaxations.rel0, coramin.relaxations.PWMcCormickRelaxation) + ) + self.assertIn(m.x, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertIn(m.y, ComponentSet(rel.relaxations.rel0.get_rhs_vars())) + self.assertEqual(id(rel.aux_vars[1]), id(rel.relaxations.rel0.get_aux_var())) + relaxations = list(coramin.relaxations.relaxation_data_objects(rel)) + self.assertEqual(len(relaxations), 1) + + def test_div3(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var(bounds=(1, 2)) + m.z = pe.Var() + m.w = pe.Var() + m.c = pe.Constraint(expr=m.z - m.x / m.y == 0) + m.c2 = pe.Constraint(expr=m.w - m.x / m.y == 0) + rel = coramin.relaxations.relax(m) + relaxations = list(coramin.relaxations.relaxation_data_objects(rel)) + constraints = list( + coramin.relaxations.nonrelaxation_component_data_objects( + rel, ctype=pe.Constraint + ) + ) + self.assertEqual(len(relaxations), 1) + self.assertEqual(len(constraints), 2) + r = relaxations[0] + c1 = constraints[0] + c2 = constraints[1] + self.assertIsInstance(r, coramin.relaxations.PWMcCormickRelaxationData) + c1_vars = ComponentSet(identify_variables(c1.body)) + c2_vars = ComponentSet(identify_variables(c2.body)) + self.assertEqual(len(c1_vars), 2) + self.assertEqual(len(c2_vars), 2) + self.assertEqual(len(rel.aux_vars), 1) + self.assertIn(rel.aux_vars[1], c1_vars) + self.assertIn(rel.aux_vars[1], c2_vars) + self.assertTrue(m.z in c1_vars or m.z in c2_vars) + self.assertTrue(m.w in c1_vars or m.w in c2_vars) + r_vars = ComponentSet(r.get_rhs_vars()) + self.assertIn(m.y, r_vars) + self.assertIn(rel.aux_vars[1], r_vars) + self.assertIs(r.get_aux_var(), m.x) + + +def _log_of_linear(x): + return pe.log(2 * x + 1) + + +def _log_of_polynomial(x): + return pe.log(x**7 + x**5 + x**3 + x) + + +def _x_times_x(x): + return x * x + + +def _quadratic(x): + return x**2 + + +def _sqrt(x): + return x**0.5 + + +def _fractional_exp(x): + return x**1.5 + + +def _variable_exp(x): + return 1.2**x + + +def _cubic(x): + return x**3 + + +def _pow_neg_2(x): + return x**-2 + + +def _pow_neg_3(x): + return x**-3 + + +def _pow_neg_point5(x): + return x ** (-0.5) + + +def _pow_neg_1point2(x): + return x ** (-1.2) + + +@unittest.skipUnless(gurobi_available, 'Gurobi is not available') +class TestUnivariate(unittest.TestCase): + def helper(self, func, bounds_list): + for relaxation_side in [ + RelaxationSide.UNDER, + RelaxationSide.OVER, + RelaxationSide.BOTH, + ]: + for lb, ub in bounds_list: + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(lb, ub)) + m.aux = pe.Var() + expr = func(m.x) + if relaxation_side == coramin.utils.RelaxationSide.BOTH: + m.c = pe.Constraint(expr=m.aux == expr) + elif relaxation_side == coramin.utils.RelaxationSide.UNDER: + m.c = pe.Constraint(expr=m.aux >= expr) + elif relaxation_side == coramin.utils.RelaxationSide.OVER: + m.c = pe.Constraint(expr=m.aux <= expr) + coramin.relaxations.relax(m) + opt = appsi.solvers.Gurobi() + all_vars = list(m.component_data_objects(pe.Var, descend_into=True)) + m.obj = pe.Objective(expr=sum(i**2 for i in all_vars)) + + # make sure the original curve is feasible for the relaxation + for _x in [float(i) for i in np.linspace(lb, ub, 10)]: + m.x.fix(_x) + m.aux.fix(pe.value(expr)) + res = opt.solve(m) + self.assertEqual( + res.termination_condition, + appsi.base.TerminationCondition.optimal, + ) + if relaxation_side == coramin.utils.RelaxationSide.UNDER: + m.aux.fix(max(pe.value(func(lb)), pe.value(func(ub))) + 1) + res = opt.solve(m) + self.assertEqual( + res.termination_condition, + appsi.base.TerminationCondition.optimal, + ) + elif relaxation_side == coramin.utils.RelaxationSide.OVER: + m.aux.fix(min(pe.value(func(lb)), pe.value(func(ub))) - 1) + res = opt.solve(m) + self.assertEqual( + res.termination_condition, + appsi.base.TerminationCondition.optimal, + ) + + # ensure the relaxation is exact at the bounds of x + m.aux.unfix() + del m.obj + m.obj = pe.Objective(expr=m.aux) + for _x in [lb, ub]: + m.x.fix(_x) + if relaxation_side in { + coramin.utils.RelaxationSide.BOTH, + coramin.utils.RelaxationSide.UNDER, + }: + m.obj.sense = pe.minimize + res = opt.solve(m) + self.assertEqual( + res.termination_condition, + appsi.base.TerminationCondition.optimal, + ) + self.assertAlmostEqual(m.aux.value, pe.value(func(_x))) + if relaxation_side in { + coramin.utils.RelaxationSide.BOTH, + coramin.utils.RelaxationSide.OVER, + }: + m.obj.sense = pe.maximize + res = opt.solve(m) + self.assertEqual( + res.termination_condition, + appsi.base.TerminationCondition.optimal, + ) + self.assertAlmostEqual(m.aux.value, pe.value(func(_x)), 5) + + def test_exp(self): + self.helper(func=pe.exp, bounds_list=[(-1, 1)]) + + def test_log(self): + self.helper(func=pe.log, bounds_list=[(0.5, 1.5)]) + + def test_log10(self): + self.helper(func=pe.log10, bounds_list=[(0.5, 1.5)]) + + def test_log_of_linear(self): + self.helper(func=_log_of_linear, bounds_list=[(0.5, 1.5)]) + + def test_log_of_polynomial(self): + self.helper(func=_log_of_polynomial, bounds_list=[(0.1, 2)]) + + def test_x_times_x(self): + self.helper(func=_x_times_x, bounds_list=[(0.1, 2)]) + + def test_quadratic(self): + self.helper(func=_quadratic, bounds_list=[(-1, 2)]) + + def test_arctan(self): + self.helper(func=pe.atan, bounds_list=[(-1, 1)]) + + def test_sin(self): + self.helper(func=pe.sin, bounds_list=[(-1, 1)]) + + def test_cos(self): + self.helper(func=pe.cos, bounds_list=[(-1, 1)]) + + def test_sqrt(self): + self.helper(func=_sqrt, bounds_list=[(0.5, 2)]) + + def test_sqrt2(self): + self.helper(func=pe.sqrt, bounds_list=[(0.5, 2)]) + + def test_variable_exp(self): + self.helper(func=_variable_exp, bounds_list=[(-2, 3)]) + + def test_cubic(self): + self.helper(func=_cubic, bounds_list=[(-2, 3), (-3, -1), (1, 3)]) + + def test_fractional_exp(self): + self.helper(func=_fractional_exp, bounds_list=[(0.5, 3)]) + + def test_pow_neg2(self): + self.helper(func=_pow_neg_2, bounds_list=[(0.5, 3), (-3, -0.5)]) + + def test_pow_neg3(self): + self.helper(func=_pow_neg_3, bounds_list=[(0.5, 3), (-3, -0.5)]) + + def test_pow_neg_point5(self): + self.helper(func=_pow_neg_point5, bounds_list=[(0.5, 3)]) + + def test_pow_neg_1point2(self): + self.helper(func=_pow_neg_1point2, bounds_list=[(0.5, 3)]) + + +class TestRepeatedTerms(unittest.TestCase): + def helper(self, func, lb, ub): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(lb, ub)) + m.aux1 = pe.Var() + m.aux2 = pe.Var() + m.c1 = pe.Constraint(expr=m.aux1 <= 2 * func(m.x) + 3) + m.c2 = pe.Constraint(expr=m.aux2 >= 3 * func(m.x) + 2) + orig = m + m = coramin.relaxations.relax(m) + rels = list(coramin.relaxations.relaxation_data_objects(m)) + self.assertEqual(len(rels), 1) + r = rels[0] + self.assertEqual(r.relaxation_side, coramin.utils.RelaxationSide.BOTH) + + def test_exp(self): + self.helper(func=pe.exp, lb=-1, ub=1) + + def test_log(self): + self.helper(func=pe.log, lb=0.5, ub=1.5) + + def test_log10(self): + self.helper(func=pe.log10, lb=0.5, ub=1.5) + + def test_quadratic(self): + def func(x): + return x**2 + + self.helper(func=func, lb=-1, ub=2) + + def test_arctan(self): + self.helper(func=pe.atan, lb=-1, ub=1) + + def test_sin(self): + self.helper(func=pe.sin, lb=-1, ub=1) + + def test_cos(self): + self.helper(func=pe.cos, lb=-1, ub=1) + + +class TestDegree0(unittest.TestCase): + def helper(self, func, param_val): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.aux = pe.Var() + m.p = pe.Param(mutable=True, initialize=param_val) + m.c = pe.Constraint(expr=m.aux == func(m.p) * m.x**2) + orig = m + m = coramin.relaxations.relax(m) + rels = list(coramin.relaxations.relaxation_data_objects(m)) + self.assertEqual(len(rels), 1) + r = rels[0] + self.assertIsInstance(r, coramin.relaxations.PWXSquaredRelaxationData) + assertExpressionsEqual( + self, m.linear.cons[1].body, orig.aux - func(param_val) * m.aux_vars[1] + ) + self.assertEqual(m.linear.cons[1].lb, 0) + self.assertEqual(m.linear.cons[1].ub, 0) + + def test_exp(self): + self.helper(func=pe.exp, param_val=1) + + def test_log(self): + self.helper(func=pe.log, param_val=1.5) + + def test_log10(self): + self.helper(func=pe.log10, param_val=1.5) + + def test_arctan(self): + self.helper(func=pe.atan, param_val=0.5) + + def test_sin(self): + self.helper(func=pe.sin, param_val=0.5) + + def test_cos(self): + self.helper(func=pe.cos, param_val=0.5) diff --git a/pyomo/contrib/coramin/relaxations/tests/test_copy.py b/pyomo/contrib/coramin/relaxations/tests/test_copy.py new file mode 100644 index 00000000000..67567843046 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/tests/test_copy.py @@ -0,0 +1,240 @@ +from pyomo.contrib.coramin.relaxations.copy_relaxation import ( + copy_relaxation_with_local_data, +) +from pyomo.common import unittest +import pyomo.environ as pe +from pyomo.contrib import coramin +from pyomo.common.collections import ComponentSet +from pyomo.core.expr.sympy_tools import sympyify_expression + + +class TestCopyRelWithLocalData(unittest.TestCase): + def test_quadratic(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.aux = pe.Var() + m.rel = coramin.relaxations.PWXSquaredRelaxation() + m.rel.build( + x=m.x, + aux_var=m.aux, + pw_repn='LOG', + relaxation_side=coramin.utils.RelaxationSide.OVER, + use_linear_relaxation=False, + ) + m2 = pe.ConcreteModel() + m2.x = pe.Var(bounds=(-1, 1)) + m2.aux = pe.Var() + new_rel = copy_relaxation_with_local_data( + m.rel, {id(m.x): m2.x, id(m.aux): m2.aux} + ) + self.assertIn(m2.x, ComponentSet(new_rel.get_rhs_vars())) + self.assertEqual(len(new_rel.get_rhs_vars()), 1) + self.assertEqual(m.rel.use_linear_relaxation, new_rel.use_linear_relaxation) + self.assertEqual(m.rel.relaxation_side, new_rel.relaxation_side) + self.assertIs(m2.aux, new_rel.get_aux_var()) + self.assertEqual(m.rel._pw_repn, new_rel._pw_repn) + self.assertEqual(m.rel._function_shape, new_rel._function_shape) + self.assertIsInstance(new_rel, coramin.relaxations.PWXSquaredRelaxationData) + + def test_arctan(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.aux = pe.Var() + m.rel = coramin.relaxations.PWArctanRelaxation() + m.rel.build( + x=m.x, + aux_var=m.aux, + pw_repn='LOG', + relaxation_side=coramin.utils.RelaxationSide.OVER, + use_linear_relaxation=True, + ) + m2 = pe.ConcreteModel() + m2.x = pe.Var(bounds=(-1, 1)) + m2.aux = pe.Var() + new_rel = copy_relaxation_with_local_data( + m.rel, {id(m.x): m2.x, id(m.aux): m2.aux} + ) + self.assertIn(m2.x, ComponentSet(new_rel.get_rhs_vars())) + self.assertEqual(len(new_rel.get_rhs_vars()), 1) + self.assertEqual(m.rel.use_linear_relaxation, new_rel.use_linear_relaxation) + self.assertEqual(m.rel.relaxation_side, new_rel.relaxation_side) + self.assertIs(m2.aux, new_rel.get_aux_var()) + self.assertEqual(m.rel._pw_repn, new_rel._pw_repn) + self.assertEqual(m.rel._function_shape, new_rel._function_shape) + self.assertIsInstance(new_rel, coramin.relaxations.PWArctanRelaxationData) + + def test_sin(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.aux = pe.Var() + m.rel = coramin.relaxations.PWSinRelaxation() + m.rel.build( + x=m.x, + aux_var=m.aux, + pw_repn='LOG', + relaxation_side=coramin.utils.RelaxationSide.OVER, + use_linear_relaxation=True, + ) + m2 = pe.ConcreteModel() + m2.x = pe.Var(bounds=(-1, 1)) + m2.aux = pe.Var() + new_rel = copy_relaxation_with_local_data( + m.rel, {id(m.x): m2.x, id(m.aux): m2.aux} + ) + self.assertIn(m2.x, ComponentSet(new_rel.get_rhs_vars())) + self.assertEqual(len(new_rel.get_rhs_vars()), 1) + self.assertEqual(m.rel.use_linear_relaxation, new_rel.use_linear_relaxation) + self.assertEqual(m.rel.relaxation_side, new_rel.relaxation_side) + self.assertIs(m2.aux, new_rel.get_aux_var()) + self.assertEqual(m.rel._pw_repn, new_rel._pw_repn) + self.assertEqual(m.rel._function_shape, new_rel._function_shape) + self.assertIsInstance(new_rel, coramin.relaxations.PWSinRelaxationData) + + def test_cos(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.aux = pe.Var() + m.rel = coramin.relaxations.PWCosRelaxation() + m.rel.build( + x=m.x, + aux_var=m.aux, + pw_repn='LOG', + relaxation_side=coramin.utils.RelaxationSide.UNDER, + use_linear_relaxation=True, + ) + m2 = pe.ConcreteModel() + m2.x = pe.Var(bounds=(-1, 1)) + m2.aux = pe.Var() + new_rel = copy_relaxation_with_local_data( + m.rel, {id(m.x): m2.x, id(m.aux): m2.aux} + ) + self.assertIn(m2.x, ComponentSet(new_rel.get_rhs_vars())) + self.assertEqual(len(new_rel.get_rhs_vars()), 1) + self.assertEqual(m.rel.use_linear_relaxation, new_rel.use_linear_relaxation) + self.assertEqual(m.rel.relaxation_side, new_rel.relaxation_side) + self.assertIs(m2.aux, new_rel.get_aux_var()) + self.assertEqual(m.rel._pw_repn, new_rel._pw_repn) + self.assertEqual(m.rel._function_shape, new_rel._function_shape) + self.assertIsInstance(new_rel, coramin.relaxations.PWCosRelaxationData) + + def test_exp(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.aux = pe.Var() + m.rel = coramin.relaxations.PWUnivariateRelaxation() + m.rel.build( + x=m.x, + aux_var=m.aux, + shape=coramin.utils.FunctionShape.CONVEX, + f_x_expr=pe.exp(m.x), + pw_repn='LOG', + relaxation_side=coramin.utils.RelaxationSide.UNDER, + use_linear_relaxation=True, + ) + m2 = pe.ConcreteModel() + m2.x = pe.Var(bounds=(-1, 1)) + m2.aux = pe.Var() + new_rel = copy_relaxation_with_local_data( + m.rel, {id(m.x): m2.x, id(m.aux): m2.aux} + ) + self.assertIn(m2.x, ComponentSet(new_rel.get_rhs_vars())) + self.assertEqual(len(new_rel.get_rhs_vars()), 1) + self.assertEqual(m.rel.use_linear_relaxation, new_rel.use_linear_relaxation) + self.assertEqual(m.rel.relaxation_side, new_rel.relaxation_side) + self.assertIs(m2.aux, new_rel.get_aux_var()) + self.assertEqual(m.rel._pw_repn, new_rel._pw_repn) + self.assertEqual(m.rel._function_shape, new_rel._function_shape) + self.assertIsInstance(new_rel, coramin.relaxations.PWUnivariateRelaxationData) + + def test_log(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(0.5, 1.5)) + m.aux = pe.Var() + m.rel = coramin.relaxations.PWUnivariateRelaxation() + m.rel.build( + x=m.x, + aux_var=m.aux, + shape=coramin.utils.FunctionShape.CONCAVE, + f_x_expr=pe.log(m.x), + pw_repn='LOG', + relaxation_side=coramin.utils.RelaxationSide.UNDER, + use_linear_relaxation=True, + ) + m2 = pe.ConcreteModel() + m2.x = pe.Var(bounds=(0.5, 1.5)) + m2.aux = pe.Var() + new_rel = copy_relaxation_with_local_data( + m.rel, {id(m.x): m2.x, id(m.aux): m2.aux} + ) + self.assertIn(m2.x, ComponentSet(new_rel.get_rhs_vars())) + self.assertEqual(len(new_rel.get_rhs_vars()), 1) + self.assertEqual(m.rel.use_linear_relaxation, new_rel.use_linear_relaxation) + self.assertEqual(m.rel.relaxation_side, new_rel.relaxation_side) + self.assertIs(m2.aux, new_rel.get_aux_var()) + self.assertEqual(m.rel._pw_repn, new_rel._pw_repn) + self.assertEqual(m.rel._function_shape, new_rel._function_shape) + self.assertIsInstance(new_rel, coramin.relaxations.PWUnivariateRelaxationData) + + def test_multivariate(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var(bounds=(-1, 1)) + m.aux = pe.Var() + m.rel = coramin.relaxations.MultivariateRelaxation() + m.rel.build( + aux_var=m.aux, + shape=coramin.utils.FunctionShape.CONVEX, + f_x_expr=(m.x**2 + m.y**2), + use_linear_relaxation=True, + ) + m2 = pe.ConcreteModel() + m2.x = pe.Var(bounds=(-1, 1)) + m2.y = pe.Var(bounds=(-1, 1)) + m2.aux = pe.Var() + new_rel = copy_relaxation_with_local_data( + m.rel, {id(m.x): m2.x, id(m.y): m2.y, id(m.aux): m2.aux} + ) + self.assertEqual(m.rel.use_linear_relaxation, new_rel.use_linear_relaxation) + self.assertEqual(m.rel.relaxation_side, new_rel.relaxation_side) + rhs_vars = ComponentSet(new_rel.get_rhs_vars()) + self.assertIn(m2.x, rhs_vars) + self.assertIn(m2.y, rhs_vars) + self.assertEqual(len(rhs_vars), 2) + self.assertIs(m2.aux, new_rel.get_aux_var()) + self.assertEqual(m.rel._function_shape, new_rel._function_shape) + self.assertIsInstance(new_rel, coramin.relaxations.MultivariateRelaxationData) + self.assertEqual( + sympyify_expression(m2.x**2 + m2.y**2 - new_rel.get_rhs_expr())[1], 0 + ) + + def test_multivariate2(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var(bounds=(-1, 1)) + m.aux = pe.Var() + m.rel = coramin.relaxations.MultivariateRelaxation() + m.rel.build( + aux_var=m.aux, + shape=coramin.utils.FunctionShape.CONCAVE, + f_x_expr=(-m.x**2 - m.y**2), + use_linear_relaxation=True, + ) + m2 = pe.ConcreteModel() + m2.x = pe.Var(bounds=(-1, 1)) + m2.y = pe.Var(bounds=(-1, 1)) + m2.aux = pe.Var() + new_rel = copy_relaxation_with_local_data( + m.rel, {id(m.x): m2.x, id(m.y): m2.y, id(m.aux): m2.aux} + ) + self.assertEqual(m.rel.use_linear_relaxation, new_rel.use_linear_relaxation) + self.assertEqual(m.rel.relaxation_side, new_rel.relaxation_side) + rhs_vars = ComponentSet(new_rel.get_rhs_vars()) + self.assertIn(m2.x, rhs_vars) + self.assertIn(m2.y, rhs_vars) + self.assertEqual(len(rhs_vars), 2) + self.assertIs(m2.aux, new_rel.get_aux_var()) + self.assertEqual(m.rel._function_shape, new_rel._function_shape) + self.assertIsInstance(new_rel, coramin.relaxations.MultivariateRelaxationData) + self.assertEqual( + sympyify_expression(-m2.x**2 - m2.y**2 - new_rel.get_rhs_expr())[1], 0 + ) diff --git a/pyomo/contrib/coramin/relaxations/tests/test_iterators.py b/pyomo/contrib/coramin/relaxations/tests/test_iterators.py new file mode 100644 index 00000000000..d34c742aa39 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/tests/test_iterators.py @@ -0,0 +1,111 @@ +from pyomo.contrib import coramin +from pyomo.common import unittest +import pyomo.environ as pe +from pyomo.common.collections import ComponentSet + + +class TestIterators(unittest.TestCase): + def setUp(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(0.5, 1.5)) + m.y = pe.Var() + m.c1 = pe.Constraint(expr=m.y == m.x) + m.r1 = coramin.relaxations.PWUnivariateRelaxation() + m.r1.set_input( + x=m.x, + aux_var=m.y, + shape=coramin.utils.FunctionShape.CONCAVE, + f_x_expr=pe.log(m.x), + ) + m.r1.add_partition_point(value=1) + m.r1.rebuild() + m.b1 = pe.Block() + m.b1.x = pe.Var(bounds=(0.5, 1.5)) + m.b1.y = pe.Var() + m.b1.c1 = pe.Constraint(expr=m.b1.y == m.b1.x) + m.b1.r1 = coramin.relaxations.PWUnivariateRelaxation() + m.b1.r1.set_input( + x=m.b1.x, + aux_var=m.b1.y, + shape=coramin.utils.FunctionShape.CONCAVE, + f_x_expr=pe.log(m.b1.x), + ) + m.b1.r1.add_partition_point(value=1) + m.b1.r1.rebuild() + m.b1.b1 = pe.Block() + + self.m = m + + def test_relaxation_data_objects(self): + m = self.m + rels = list( + coramin.relaxations.relaxation_data_objects( + m, descend_into=True, active=True + ) + ) + self.assertEqual(len(rels), 2) + self.assertIn(m.r1, rels) + self.assertIn(m.b1.r1, rels) + + m.r1.deactivate() + rels = list( + coramin.relaxations.relaxation_data_objects( + m, descend_into=True, active=True + ) + ) + self.assertEqual(len(rels), 1) + self.assertNotIn(m.r1, rels) + self.assertIn(m.b1.r1, rels) + + rels = list( + coramin.relaxations.relaxation_data_objects( + m, descend_into=True, active=None + ) + ) + self.assertEqual(len(rels), 2) + self.assertIn(m.r1, rels) + self.assertIn(m.b1.r1, rels) + + m.r1.activate() + rels = list(coramin.relaxations.relaxation_data_objects(m, descend_into=False)) + self.assertEqual(len(rels), 1) + self.assertIn(m.r1, rels) + self.assertNotIn(m.b1.r1, rels) + + def test_nonrelaxation_component_data_objects(self): + m = self.m + all_vars = list(m.component_data_objects(pe.Var, descend_into=True)) + non_relaxation_vars = list( + coramin.relaxations.nonrelaxation_component_data_objects( + m, ctype=pe.Var, descend_into=True + ) + ) + self.assertEqual(len(non_relaxation_vars), 4) + self.assertGreater(len(all_vars), 4) + + all_vars = list(m.component_data_objects(pe.Var, descend_into=False)) + non_relaxation_vars = list( + coramin.relaxations.nonrelaxation_component_data_objects( + m, ctype=pe.Var, descend_into=False + ) + ) + self.assertEqual(len(non_relaxation_vars), 2) + self.assertEqual(len(all_vars), 2) + + all_blocks = list(m.component_data_objects(pe.Block, descend_into=True)) + non_relaxation_blocks = list( + coramin.relaxations.nonrelaxation_component_data_objects( + m, ctype=pe.Block, descend_into=True + ) + ) + self.assertEqual(len(non_relaxation_blocks), 2) + self.assertEqual(len(all_blocks), 8) + + all_blocks = list(m.component_data_objects(pe.Block, descend_into=False)) + non_relaxation_blocks = list( + coramin.relaxations.nonrelaxation_component_data_objects( + m, ctype=pe.Block, descend_into=False + ) + ) + self.assertEqual(len(non_relaxation_blocks), 1) + self.assertEqual(len(all_blocks), 2) diff --git a/pyomo/contrib/coramin/relaxations/tests/test_mccormick.py b/pyomo/contrib/coramin/relaxations/tests/test_mccormick.py new file mode 100644 index 00000000000..89d213c8f49 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/tests/test_mccormick.py @@ -0,0 +1,122 @@ +import pyomo.environ as pyo +from pyomo.common import unittest +from pyomo.contrib import coramin +from pyomo.contrib import appsi + + +gurobi_available = appsi.solvers.Gurobi().available() + + +@unittest.skipUnless(gurobi_available, 'Gurobi is not available') +class TestMcCormick(unittest.TestCase): + @classmethod + def setUpClass(cls): + pass + + @classmethod + def tearDownClass(cls): + pass + + def test_mccormick1(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(bounds=(0, 6)) + model.y = pyo.Var(bounds=(0, 3)) + model.w = pyo.Var() + + model.obj = pyo.Objective(expr=-model.w - 2 * model.x) + model.con = pyo.Constraint(expr=model.w <= 12) + model.mc = coramin.relaxations.PWMcCormickRelaxation() + model.mc.build(x1=model.x, x2=model.y, aux_var=model.w) + + linsolver = pyo.SolverFactory('gurobi_direct') + linsolver.solve(model) + self.assertAlmostEqual(pyo.value(model.x), 6.0, 6) + self.assertAlmostEqual(pyo.value(model.y), 2.0, 6) + + def test_mccormick2(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(bounds=(0, 6)) + model.y = pyo.Var(bounds=(0, 3)) + model.w = pyo.Var() + + model.obj = pyo.Objective(expr=-model.w - 2 * model.x) + model.con = pyo.Constraint(expr=model.w <= 12) + + def mc_rule(b): + b.build(x1=model.x, x2=model.y, aux_var=model.w) + + model.mc = coramin.relaxations.PWMcCormickRelaxation(rule=mc_rule) + + linsolver = pyo.SolverFactory('gurobi_direct') + linsolver.solve(model) + self.assertAlmostEqual(pyo.value(model.x), 6.0, 6) + self.assertAlmostEqual(pyo.value(model.y), 2.0, 6) + + def test_mccormick3_BOTH(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(bounds=(0, 6)) + model.y = pyo.Var(bounds=(0, 3)) + model.w = pyo.Var() + + model.obj = pyo.Objective(expr=-model.w - 2 * model.x) + model.con = pyo.Constraint(expr=model.w <= 12) + + def mc_rule(b): + m = b.parent_block() + b.build(x1=m.x, x2=m.y, aux_var=m.w) + + model.mc = coramin.relaxations.PWMcCormickRelaxation(rule=mc_rule) + + linsolver = pyo.SolverFactory('gurobi_direct', tee=True) + linsolver.solve(model) + self.assertAlmostEqual(pyo.value(model.x), 6.0, 6) + self.assertAlmostEqual(pyo.value(model.y), 2.0, 6) + + def test_mccormick3_OVER(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(bounds=(0, 6)) + model.y = pyo.Var(bounds=(0, 3)) + model.w = pyo.Var() + + model.obj = pyo.Objective(expr=-model.w + 0.1 * model.x + 0.1 * model.y) + model.con = pyo.Constraint(expr=model.w <= 12) + + def mc_rule(b): + m = b.parent_block() + b.build( + x1=m.x, + x2=m.y, + aux_var=m.w, + relaxation_side=coramin.utils.RelaxationSide.OVER, + ) + + model.mc = coramin.relaxations.PWMcCormickRelaxation(rule=mc_rule) + + linsolver = pyo.SolverFactory('gurobi_direct') + linsolver.solve(model) + self.assertAlmostEqual(pyo.value(model.x), 4.0, 6) + self.assertAlmostEqual(pyo.value(model.y), 2.0, 6) + + def test_mccormick3_UNDER(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(bounds=(0, 6)) + model.y = pyo.Var(bounds=(0, 3)) + model.w = pyo.Var() + + model.obj = pyo.Objective(expr=-model.w - 2 * model.x) + model.con = pyo.Constraint(expr=model.w <= 12) + + def mc_rule(b): + m = b.parent_block() + b.build( + x1=m.x, + x2=m.y, + aux_var=m.w, + relaxation_side=coramin.utils.RelaxationSide.UNDER, + ) + + model.mc = coramin.relaxations.PWMcCormickRelaxation(rule=mc_rule) + + linsolver = pyo.SolverFactory('gurobi_direct', tee=True) + linsolver.solve(model) + self.assertAlmostEqual(pyo.value(model.w), 12.0, 6) diff --git a/pyomo/contrib/coramin/relaxations/tests/test_relaxations.py b/pyomo/contrib/coramin/relaxations/tests/test_relaxations.py new file mode 100644 index 00000000000..5de31f81a62 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/tests/test_relaxations.py @@ -0,0 +1,1258 @@ +from pyomo.common import unittest +import pyomo.environ as pe +from pyomo.contrib import coramin +from pyomo.core.base.block import _BlockData +from pyomo.core.base.var import _GeneralVarData +from pyomo.core.expr.numeric_expr import ExpressionBase +from typing import Sequence, List, Tuple +from pyomo.common.dependencies import numpy as np +import itertools +from pyomo.contrib import appsi +from pyomo.core.expr.visitor import identify_variables +from pyomo.common.collections import ComponentSet +from pyomo.core.expr.compare import compare_expressions +from pyomo.repn.standard_repn import generate_standard_repn +from pyomo.util.report_scaling import _check_coefficients +from pyomo.core.expr.calculus.derivatives import differentiate, Modes, reverse_sd +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr +from pyomo.core.expr import sympy_tools +import io + + +gurobi_available = appsi.solvers.Gurobi().available() + + +def _grid_rhs_vars( + v_list: Sequence[_GeneralVarData], num_points: int = 30 +) -> List[Tuple[float, ...]]: + res = list() + for v in v_list: + res.append(np.linspace(v.lb, v.ub, num_points)) + res = list(tuple(float(p) for p in i) for i in itertools.product(*res)) + return res + + +def _get_rhs_vals( + rhs_vars: Sequence[_GeneralVarData], + rhs_expr: ExpressionBase, + eval_pts: List[Tuple[float, ...]], +) -> List[float]: + rhs_vals = list() + for pt in eval_pts: + for v, p in zip(rhs_vars, pt): + v.fix(p) + rhs_vals.append(pe.value(rhs_expr)) + for v in rhs_vars: + v.unfix() + return rhs_vals + + +def _get_relaxation_vals( + rhs_vars: Sequence[_GeneralVarData], + rhs_expr: ExpressionBase, + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + eval_pts: List[Tuple[float, ...]], + rel_side: coramin.utils.RelaxationSide, + linear: bool = True, +) -> List[float]: + opt = appsi.solvers.Gurobi() + opt.update_config.update_vars = True + opt.update_config.check_for_new_or_removed_vars = False + opt.update_config.check_for_new_or_removed_constraints = False + opt.update_config.check_for_new_or_removed_params = False + opt.update_config.update_constraints = False + opt.update_config.update_params = False + opt.update_config.update_named_expressions = False + opt.update_config.check_for_new_objective = False + opt.update_config.update_objective = False + if linear: + opt.update_config.treat_fixed_vars_as_params = False + + if rel_side == coramin.utils.RelaxationSide.UNDER: + sense = pe.minimize + else: + sense = pe.maximize + m.obj = pe.Objective(expr=rel.get_aux_var(), sense=sense) + + under_est_vals = list() + for pt in eval_pts: + for v, p in zip(rhs_vars, pt): + v.fix(p) + res = opt.solve(m) + assert res.termination_condition == appsi.base.TerminationCondition.optimal + under_est_vals.append(rel.get_aux_var().value) + + del m.obj + for v in rhs_vars: + v.unfix() + return under_est_vals + + +def _num_cons(rel): + cons = list( + rel.component_data_objects(pe.Constraint, descend_into=True, active=True) + ) + return len(cons) + + +def _check_unbounded( + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + rel_side: coramin.utils.RelaxationSide, + linear: bool = True, +): + if rel_side == coramin.utils.RelaxationSide.UNDER: + sense = pe.minimize + else: + sense = pe.maximize + m.obj = pe.Objective(expr=rel.get_aux_var(), sense=sense) + + unfixed_vars = list() + for v in rel.get_rhs_vars(): + if v.is_fixed(): + continue + unfixed_vars.append(v) + if v.has_lb() and v.has_ub(): + v.fix(0.5 * (v.lb + v.ub)) + elif v.has_lb(): + v.fix(v.lb + 0.1) + elif v.has_ub(): + v.fix(v.ub - 0.1) + else: + v.fix(1) + + opt = appsi.solvers.Gurobi() + opt.gurobi_options['DualReductions'] = 0 + opt.config.load_solution = False + res = opt.solve(m) + + del m.obj + + for v in unfixed_vars: + v.unfix() + + return res.termination_condition == appsi.base.TerminationCondition.unbounded + + +def _check_linear(m: _BlockData): + for c in m.component_data_objects(pe.Constraint, descend_into=True, active=True): + repn = generate_standard_repn(c.body) + if not repn.is_linear(): + return False + return True + + +def _check_linear_or_convex(rel: coramin.relaxations.BaseRelaxationData): + for c in rel.component_data_objects(pe.Constraint, descend_into=True, active=True): + repn = generate_standard_repn(c.body, quadratic=False) + if repn.is_linear(): + continue + + if c.lower is not None and c.upper is not None: + return False # nonlinear equality constraints are not convex + + # reconstruct the expression without the aux_var + e = repn.constant + for coef, v in zip(repn.linear_coefs, repn.linear_vars): + if v is not rel.get_aux_var(): + e += coef * v + e += repn.nonlinear_expr + + # this will only work if all the off-diagonal elements of the hessian are 0 + rhs_vars = rel.get_rhs_vars() + ders = reverse_sd(e) + for v1 in rhs_vars: + v1_der = ders[v1] + hes = reverse_sd(v1_der) + for v2 in rhs_vars: + if v2 is not v1: + assert v2 not in hes + hes = differentiate(v1_der, wrt=v1, mode=Modes.reverse_symbolic) + if type(hes) not in {int, float}: + om, se = sympy_tools.sympyify_expression(hes) + se = se.simplify() + hes = sympy_tools.sympy2pyomo_expression(se, om) + hes_lb, hes_ub = compute_bounds_on_expr(hes) + if c.lower is not None: + if hes_ub > 0: + return False + else: + assert c.upper is not None + if hes_lb < 0: + return False + + return True + + +def _check_scaling(m: _BlockData, rel: coramin.relaxations.BaseRelaxationData) -> bool: + cons_with_large_coefs = dict() + cons_with_small_coefs = dict() + for c in m.component_data_objects(pe.Constraint, descend_into=True, active=True): + _check_coefficients( + c, + c.body, + rel.large_coef, + rel.small_coef, + cons_with_large_coefs, + cons_with_small_coefs, + ) + passed = len(cons_with_large_coefs) == 0 and len(cons_with_small_coefs) == 0 + return passed + + +@unittest.skipUnless(gurobi_available, 'Gurobi is not available') +class TestRelaxationBasics(unittest.TestCase): + def valid_relaxation_helper( + self, + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + num_points: int = 30, + check_underestimator: bool = True, + check_overestimator: bool = True, + ): + if rel.use_linear_relaxation and all(v.lb != v.ub for v in rel.get_rhs_vars()): + self.assertTrue(_check_linear(m)) + rhs_vars = rel.get_rhs_vars() + sample_points = _grid_rhs_vars(rhs_vars, num_points=num_points) + rhs_vals = _get_rhs_vals(rhs_vars, rhs_expr, sample_points) + rhs_vals = np.array(rhs_vals) + + if check_underestimator: + under_est_vals = _get_relaxation_vals( + rhs_vars, + rhs_expr, + m, + rel, + sample_points, + coramin.utils.RelaxationSide.UNDER, + ) + under_est_vals = np.array(under_est_vals) + self.assertTrue(np.all(rhs_vals >= under_est_vals)) + if check_overestimator: + over_est_vals = _get_relaxation_vals( + rhs_vars, + rhs_expr, + m, + rel, + sample_points, + coramin.utils.RelaxationSide.OVER, + ) + over_est_vals = np.array(over_est_vals) + self.assertTrue(np.all(rhs_vals <= over_est_vals)) + + def equal_at_points_helper( + self, + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + pts: Sequence[Tuple[float, ...]], + check_underestimator: bool = True, + check_overestimator: bool = True, + linear: bool = True, + ): + rhs_vars = rel.get_rhs_vars() + rhs_vals = _get_rhs_vals(rhs_vars, rhs_expr, pts) + rhs_vals = np.array(rhs_vals) + if check_underestimator: + under_est_vals = _get_relaxation_vals( + rhs_vars, + rhs_expr, + m, + rel, + pts, + coramin.utils.RelaxationSide.UNDER, + linear, + ) + under_est_vals = np.array(under_est_vals) + self.assertTrue(np.all(np.isclose(rhs_vals, under_est_vals))) + if check_overestimator: + over_est_vals = _get_relaxation_vals( + rhs_vars, + rhs_expr, + m, + rel, + pts, + coramin.utils.RelaxationSide.OVER, + linear, + ) + over_est_vals = np.array(over_est_vals) + self.assertTrue(np.all(np.isclose(rhs_vals, over_est_vals))) + + def nonlinear_relaxation_helper( + self, + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + num_points: int = 30, + supports_underestimator: bool = True, + supports_overestimator: bool = True, + check_equal_at_points: bool = True, + ): + rel.use_linear_relaxation = False + rel.rebuild() + if rel.is_rhs_convex() or rel.is_rhs_concave(): + self.assertFalse(_check_linear(m)) + self.assertTrue(_check_linear_or_convex(rel)) + else: + self.assertTrue(_check_linear(m)) + rhs_vars = rel.get_rhs_vars() + sample_points = _grid_rhs_vars(rhs_vars, num_points=num_points) + rhs_vals = _get_rhs_vals(rhs_vars, rhs_expr, sample_points) + rhs_vals = np.array(rhs_vals) + + if supports_underestimator: + under_est_vals = _get_relaxation_vals( + rhs_vars, + rhs_expr, + m, + rel, + sample_points, + coramin.utils.RelaxationSide.UNDER, + linear=False, + ) + under_est_vals = np.array(under_est_vals) + if rel.is_rhs_convex() and check_equal_at_points: + self.assertTrue(np.all(np.isclose(rhs_vals, under_est_vals))) + else: + self.assertTrue(np.all(rhs_vals >= under_est_vals)) + if supports_overestimator: + over_est_vals = _get_relaxation_vals( + rhs_vars, + rhs_expr, + m, + rel, + sample_points, + coramin.utils.RelaxationSide.OVER, + linear=False, + ) + over_est_vals = np.array(over_est_vals) + if rel.is_rhs_concave() and check_equal_at_points: + self.assertTrue(np.all(np.isclose(rhs_vals, over_est_vals))) + else: + self.assertTrue(np.all(rhs_vals <= over_est_vals)) + + if supports_underestimator and supports_overestimator: + orig_relaxation_side = rel.relaxation_side + if rel.is_rhs_convex(): + rel.relaxation_side = coramin.utils.RelaxationSide.OVER + if rel.is_rhs_concave(): + rel.relaxation_side = coramin.utils.RelaxationSide.UNDER + rel.rebuild() + self.assertTrue(_check_linear(m)) + rel.relaxation_side = orig_relaxation_side + + rel.use_linear_relaxation = True + rel.rebuild() + + def original_constraint_helper( + self, + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + num_points: int = 15, + supports_underestimator: bool = True, + supports_overestimator: bool = True, + ): + rel.rebuild(build_nonlinear_constraint=True) + self.assertFalse(_check_linear(m)) + rhs_vars = rel.get_rhs_vars() + sample_points = _grid_rhs_vars(rhs_vars, num_points) + rhs_vals = _get_rhs_vals(rhs_vars, rhs_expr, sample_points) + rhs_vals = np.array(rhs_vals) + + if supports_underestimator: + under_est_vals = _get_relaxation_vals( + rhs_vars, + rhs_expr, + m, + rel, + sample_points, + coramin.utils.RelaxationSide.UNDER, + linear=False, + ) + under_est_vals = np.array(under_est_vals) + self.assertTrue(np.all(np.isclose(rhs_vals, under_est_vals))) + if supports_overestimator: + over_est_vals = _get_relaxation_vals( + rhs_vars, + rhs_expr, + m, + rel, + sample_points, + coramin.utils.RelaxationSide.OVER, + linear=False, + ) + over_est_vals = np.array(over_est_vals) + self.assertTrue(np.all(np.isclose(rhs_vals, over_est_vals))) + + rel.rebuild() + self.valid_relaxation_helper( + m, + rel, + rhs_expr, + num_points, + supports_underestimator, + supports_overestimator, + ) + + def relaxation_side_helper( + self, + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + check_nonlinear_relaxation: bool = True, + ): + rel.relaxation_side = coramin.utils.RelaxationSide.UNDER + rel.rebuild() + sample_points = [ + tuple(v.lb for v in rel.get_rhs_vars()), + tuple(v.ub for v in rel.get_rhs_vars()), + ] + self.equal_at_points_helper(m, rel, rhs_expr, sample_points, True, False) + self.assertTrue(_check_unbounded(m, rel, coramin.RelaxationSide.OVER)) + + rel.relaxation_side = coramin.utils.RelaxationSide.OVER + rel.rebuild() + self.equal_at_points_helper(m, rel, rhs_expr, sample_points, False, True) + self.assertTrue(_check_unbounded(m, rel, coramin.RelaxationSide.UNDER)) + + if check_nonlinear_relaxation: + rel.use_linear_relaxation = False + + rel.relaxation_side = coramin.utils.RelaxationSide.UNDER + rel.rebuild() + sample_points = [(v.lb, v.ub) for v in rel.get_rhs_vars()] + self.equal_at_points_helper( + m, rel, rhs_expr, sample_points, True, False, False + ) + self.assertTrue( + _check_unbounded(m, rel, coramin.RelaxationSide.OVER, False) + ) + + rel.relaxation_side = coramin.utils.RelaxationSide.OVER + rel.rebuild() + self.equal_at_points_helper( + m, rel, rhs_expr, sample_points, False, True, False + ) + self.assertTrue( + _check_unbounded(m, rel, coramin.RelaxationSide.UNDER, False) + ) + + rel.relaxation_side = coramin.utils.RelaxationSide.UNDER + rel.rebuild(build_nonlinear_constraint=True) + sample_points = [(v.lb, v.ub) for v in rel.get_rhs_vars()] + self.equal_at_points_helper(m, rel, rhs_expr, sample_points, True, False, False) + self.assertTrue(_check_unbounded(m, rel, coramin.RelaxationSide.OVER, False)) + + rel.relaxation_side = coramin.utils.RelaxationSide.OVER + rel.rebuild(build_nonlinear_constraint=True) + self.equal_at_points_helper(m, rel, rhs_expr, sample_points, False, True, False) + self.assertTrue(_check_unbounded(m, rel, coramin.RelaxationSide.UNDER, False)) + + rel.use_linear_relaxation = True + rel.relaxation_side = coramin.RelaxationSide.BOTH + rel.rebuild() + + def changing_bounds_helper( + self, + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + num_points: int = 10, + supports_underestimator: bool = True, + supports_overestimator: bool = True, + check_equal_at_points: bool = True, + ): + rhs_vars = rel.get_rhs_vars() + orig_bnds = pe.ComponentMap((v, (v.lb, v.ub)) for v in rhs_vars) + grid_pts = _grid_rhs_vars(rhs_vars, num_points=num_points) + for pt in grid_pts: + for v, p in zip(rhs_vars, pt): + v.setlb(p) + rel.rebuild() + self.assertLessEqual(_num_cons(rel), 4) + self.valid_relaxation_helper( + m, + rel, + rhs_expr, + num_points, + supports_underestimator, + supports_overestimator, + ) + self.equal_at_points_helper( + m, + rel, + rhs_expr, + [tuple(v.lb for v in rhs_vars), tuple(v.ub for v in rhs_vars)], + supports_underestimator, + supports_overestimator, + ) + if rel.is_rhs_convex() or rel.is_rhs_concave(): + self.nonlinear_relaxation_helper( + m, + rel, + rhs_expr, + num_points, + supports_underestimator, + supports_overestimator, + check_equal_at_points, + ) + for v, (v_lb, v_ub) in orig_bnds.items(): + v.setlb(v_lb) + v.setub(v_ub) + rel.rebuild() + self.assertLessEqual(_num_cons(rel), 4) + self.valid_relaxation_helper( + m, + rel, + rhs_expr, + num_points, + supports_underestimator, + supports_overestimator, + ) + self.equal_at_points_helper( + m, + rel, + rhs_expr, + [tuple(v.lb for v in rhs_vars), tuple(v.ub for v in rhs_vars)], + supports_underestimator, + supports_overestimator, + ) + for pt in grid_pts: + for v, p in zip(rhs_vars, pt): + v.setub(p) + rel.rebuild() + self.assertLessEqual(_num_cons(rel), 4) + self.valid_relaxation_helper( + m, + rel, + rhs_expr, + num_points, + supports_underestimator, + supports_overestimator, + ) + self.equal_at_points_helper( + m, + rel, + rhs_expr, + [tuple(v.lb for v in rhs_vars), tuple(v.ub for v in rhs_vars)], + supports_underestimator, + supports_overestimator, + ) + if rel.is_rhs_convex() or rel.is_rhs_concave(): + self.nonlinear_relaxation_helper( + m, + rel, + rhs_expr, + num_points, + supports_underestimator, + supports_overestimator, + check_equal_at_points, + ) + for v, (v_lb, v_ub) in orig_bnds.items(): + v.setlb(v_lb) + v.setub(v_ub) + rel.rebuild() + self.assertLessEqual(_num_cons(rel), 4) + self.valid_relaxation_helper( + m, + rel, + rhs_expr, + num_points, + supports_underestimator, + supports_overestimator, + ) + self.equal_at_points_helper( + m, + rel, + rhs_expr, + [tuple(v.lb for v in rhs_vars), tuple(v.ub for v in rhs_vars)], + supports_underestimator, + supports_overestimator, + ) + + def large_bounds_helper( + self, m: _BlockData, rel: coramin.relaxations.BaseRelaxationData, lb=1, ub=1e6 + ): + orig_bnds = pe.ComponentMap((v, (v.lb, v.ub)) for v in rel.get_rhs_vars()) + + for v in rel.get_rhs_vars(): + v.setlb(lb) + v.setub(ub) + rel.rebuild() + + scaling_passed = _check_scaling(m, rel) + self.assertTrue(scaling_passed) + + if rel.is_rhs_convex(): + self.assertTrue(_check_unbounded(m, rel, coramin.utils.RelaxationSide.OVER)) + elif rel.is_rhs_concave(): + self.assertTrue( + _check_unbounded(m, rel, coramin.utils.RelaxationSide.UNDER) + ) + else: + self.assertTrue( + _check_unbounded(m, rel, coramin.utils.RelaxationSide.UNDER) + ) + self.assertTrue(_check_unbounded(m, rel, coramin.utils.RelaxationSide.OVER)) + + for v, (v_lb, v_ub) in orig_bnds.items(): + v.setlb(v_lb) + v.setub(v_ub) + rel.rebuild() + + def infinite_bounds_helper( + self, m: _BlockData, rel: coramin.relaxations.BaseRelaxationData + ): + self.large_bounds_helper(m, rel, None, None) + self.large_bounds_helper(m, rel, ub=None) + self.large_bounds_helper(m, rel, lb=None) + + def oa_cuts_helper( + self, + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + num_pts: int = 30, + supports_underestimator: bool = True, + supports_overestimator: bool = True, + check_equal_at_points: bool = True, + ): + rhs_vars = rel.get_rhs_vars() + sample_points = _grid_rhs_vars(rhs_vars, 5) + for pt in sample_points: + rel.add_oa_point(pt) + rel.rebuild() + if rel.is_rhs_convex() or rel.is_rhs_concave(): + self.assertEqual(len(rel._cuts), len(sample_points)) + self.valid_relaxation_helper( + m, rel, rhs_expr, num_pts, supports_underestimator, supports_overestimator + ) + if rel.is_rhs_convex(): + check_under = True + else: + check_under = False + if rel.is_rhs_concave(): + check_over = True + else: + check_over = False + if check_equal_at_points: + self.equal_at_points_helper( + m, rel, rhs_expr, sample_points, check_under, check_over + ) + rel.push_oa_points('foo') + rel.clear_oa_points() + rel.rebuild() + if rel.has_convex_underestimator() or rel.has_concave_overestimator(): + self.assertEqual(len(rel._cuts), 2) + else: + self.assertIsNone(rel._cuts) + rel.pop_oa_points('foo') + rel.rebuild() + if rel.is_rhs_convex() or rel.is_rhs_concave(): + self.assertEqual(len(rel._cuts), len(sample_points)) + if check_equal_at_points: + self.equal_at_points_helper( + m, rel, rhs_expr, sample_points, check_under, check_over + ) + rel.clear_oa_points() + rel.rebuild() + + def add_cuts_helper( + self, + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + num_pts: int = 30, + supports_underestimator: bool = True, + supports_overestimator: bool = True, + check_equal_at_points: bool = True, + ): + rhs_vars = rel.get_rhs_vars() + sample_points = _grid_rhs_vars(rhs_vars, 5) + for keep_cut in [True, False]: + for offset in [-10, 10]: + for pt in sample_points: + for v, p in zip(rhs_vars, pt): + v.value = p + rel.get_aux_var().value = pe.value(rhs_expr) + offset + rel.add_cut(keep_cut=keep_cut, check_violation=True) + self.valid_relaxation_helper( + m, + rel, + rhs_expr, + num_pts, + supports_underestimator, + supports_overestimator, + ) + if rel.has_convex_underestimator(): + if offset < 0: + self.assertEqual(len(rel._cuts), len(sample_points)) + if check_equal_at_points: + self.equal_at_points_helper( + m, rel, rhs_expr, sample_points, True, False + ) + else: + self.assertEqual(len(rel._cuts), 2) + if rel.has_concave_overestimator(): + if offset > 0: + self.assertEqual(len(rel._cuts), len(sample_points)) + if check_equal_at_points: + self.equal_at_points_helper( + m, rel, rhs_expr, sample_points, False, True + ) + else: + self.assertEqual(len(rel._cuts), 2) + if rel.has_convex_underestimator() or rel.has_concave_overestimator(): + cuts_len = len(rel._cuts) + else: + cuts_len = None + rel.rebuild() + if keep_cut: + if ( + rel.has_convex_underestimator() + or rel.has_concave_overestimator() + ): + self.assertEqual(cuts_len, len(rel._cuts)) + else: + self.assertIsNone(rel._cuts) + else: + if ( + rel.has_convex_underestimator() + or rel.has_concave_overestimator() + ): + self.assertEqual(len(rel._cuts), 2) + else: + self.assertIsNone(rel._cuts) + rel.clear_oa_points() + rel.rebuild() + if rel.has_convex_underestimator() or rel.has_concave_overestimator(): + self.assertEqual(len(rel._cuts), 2) + else: + self.assertIsNone(rel._cuts) + + def active_partition_helper( + self, rel: coramin.relaxations.BasePWRelaxationData, partition_points + ): + rhs_var = rel.get_rhs_vars()[0] + sample_points = _grid_rhs_vars([rhs_var], 30) + partition_points.sort() + for pt in sample_points: + pt = pt[0] + rhs_var.value = pt + active_lb, active_ub = rel.get_active_partitions()[rhs_var] + assert partition_points[0] <= pt + assert partition_points[-1] >= pt + + ub_ndx = 0 + while partition_points[ub_ndx] < pt: + if ub_ndx == len(partition_points) - 1: + break + ub_ndx += 1 + if ub_ndx == 0: + ub_ndx = 1 + lb_ndx = ub_ndx - 1 + expected_lb = partition_points[lb_ndx] + expected_ub = partition_points[ub_ndx] + self.assertAlmostEqual(active_lb, expected_lb) + self.assertAlmostEqual(active_ub, expected_ub) + + def pw_helper( + self, + m: _BlockData, + rel: coramin.relaxations.BasePWRelaxationData, + rhs_expr: ExpressionBase, + ): + rhs_vars = rel.get_rhs_vars() + sample_points = _grid_rhs_vars(rhs_vars, 5) + part_points = list(set(i[0] for i in sample_points)) + part_points.sort() + for pt in part_points: + rel.add_oa_point((pt,)) + rel.add_partition_point(pt) + rel.rebuild() + self.valid_relaxation_helper(m, rel, rhs_expr) + self.equal_at_points_helper(m, rel, rhs_expr, sample_points, True, True) + self.active_partition_helper(rel, part_points) + rel.clear_oa_points() + rel.clear_partitions() + rel.rebuild() + + def util_methods_helper( + self, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + aux_var: _GeneralVarData, + expected_convex: bool, + expected_concave: bool, + supports_underestimator: bool = True, + supports_overestimator: bool = True, + ): + # test get_rhs_vars + expected = ComponentSet(identify_variables(rhs_expr)) + got = ComponentSet(rel.get_rhs_vars()) + diff = expected - got + self.assertEqual(len(diff), 0) + diff = got - expected + self.assertEqual(len(diff), 0) + self.assertEqual(type(rel.get_rhs_vars()), tuple) + + # test get_rhs_expr + expected = rhs_expr + got = rel.get_rhs_expr() + self.assertTrue(compare_expressions(expected, got)) + + # test get_aux_var + self.assertIs(rel.get_aux_var(), aux_var) + + # test convex/concave + self.assertEqual(rel.is_rhs_convex(), expected_convex) + self.assertEqual(rel.is_rhs_concave(), expected_concave) + + # test pprint + original_relaxation_side = rel.relaxation_side + if supports_underestimator and supports_overestimator: + out = io.StringIO() + rel.pprint(ostream=out) + self.assertIn( + f'{str(rel.get_aux_var())} == {str(rhs_expr)}', out.getvalue() + ) + if supports_underestimator: + rel.relaxation_side = coramin.RelaxationSide.UNDER + rel.rebuild() + out = io.StringIO() + rel.pprint(ostream=out) + self.assertIn( + f'{str(rel.get_aux_var())} >= {str(rhs_expr)}', out.getvalue() + ) + if supports_overestimator: + rel.relaxation_side = coramin.RelaxationSide.OVER + rel.rebuild() + out = io.StringIO() + rel.pprint(ostream=out) + self.assertIn( + f'{str(rel.get_aux_var())} <= {str(rhs_expr)}', out.getvalue() + ) + rel.relaxation_side = original_relaxation_side + rel.rebuild() + rel.pprint(verbose=True) # only checks that an error does not get raised... + + def deviation_helper( + self, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + supports_underestimator: bool = True, + supports_overestimator: bool = True, + ): + original_relaxation_side = rel.relaxation_side + for v in rel.get_rhs_vars(): + v.value = np.random.uniform(v.lb, v.ub) + rel.get_aux_var().value = pe.value(rhs_expr) + 1 + if supports_underestimator and supports_overestimator: + dev = rel.get_deviation() + self.assertAlmostEqual(dev, 1) + if supports_underestimator: + rel.relaxation_side = coramin.RelaxationSide.UNDER + dev = rel.get_deviation() + self.assertAlmostEqual(dev, 0) + if supports_overestimator: + rel.relaxation_side = coramin.RelaxationSide.OVER + dev = rel.get_deviation() + self.assertAlmostEqual(dev, 1) + rel.get_aux_var().value = pe.value(rhs_expr) - 1 + if supports_underestimator and supports_overestimator: + rel.relaxation_side = coramin.RelaxationSide.BOTH + dev = rel.get_deviation() + self.assertAlmostEqual(dev, 1) + if supports_underestimator: + rel.relaxation_side = coramin.RelaxationSide.UNDER + dev = rel.get_deviation() + self.assertAlmostEqual(dev, 1) + if supports_overestimator: + rel.relaxation_side = coramin.RelaxationSide.OVER + dev = rel.get_deviation() + self.assertAlmostEqual(dev, 0) + rel.relaxation_side = original_relaxation_side + + def small_coef_helper( + self, + m: _BlockData, + rel: coramin.relaxations.BaseRelaxationData, + rhs_expr: ExpressionBase, + num_points: int = 30, + check_underestimator: bool = True, + check_overestimator: bool = True, + ): + rel.small_coef = 1e10 + rel.rebuild() + self.valid_relaxation_helper( + m, rel, rhs_expr, num_points, check_underestimator, check_overestimator + ) + rel.small_coef = 1e-10 + rel.rebuild() + + def options_switching_helper(self, rel: coramin.relaxations.BaseRelaxationData): + self.assertIsNone(rel._original_constraint) + self.assertIsNone(rel._nonlinear) + self.assertIsNotNone(rel._oa_params) + self.assertIsNotNone(rel._cuts) + self.assertEqual(len(rel._cuts), 2) + rel.clear_oa_points() + self.assertEqual(len(rel._cuts), 0) + rel.add_oa_point(tuple(v.lb for v in rel.get_rhs_vars())) + self.assertEqual(len(rel._cuts), 0) + rel.rebuild(ensure_oa_at_vertices=False) + self.assertEqual(len(rel._cuts), 1) + rel.rebuild() + self.assertEqual(len(rel._cuts), 2) + rel.use_linear_relaxation = False + rel.rebuild() + self.assertIsNone(rel._original_constraint) + self.assertIsNone(rel._cuts) + self.assertIsNotNone(rel._nonlinear) + for v in rel.get_rhs_vars(): + v.value = 1 + with self.assertRaisesRegex( + ValueError, 'Can only add an OA cut when using a linear relaxation' + ): + rel.add_cut(check_violation=False) + rel.rebuild(build_nonlinear_constraint=True) + self.assertIsNotNone(rel._original_constraint) + self.assertIsNone(rel._cuts) + self.assertIsNone(rel._nonlinear) + rel.use_linear_relaxation = True + rel.rebuild() + self.assertIsNone(rel._original_constraint) + self.assertIsNotNone(rel._cuts) + self.assertIsNone(rel._nonlinear) + + def get_base_pyomo_model(self, xlb=-1.5, xub=0.8, ylb=-2, yub=1): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(xlb, xub)) + m.y = pe.Var(bounds=(ylb, yub)) + m.z = pe.Var() + return m + + def test_quadratic_relaxation(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.PWXSquaredRelaxation() + m.rel.build(x=m.x, aux_var=m.z) + e = m.x**2 + self.options_switching_helper(m.rel) + self.valid_relaxation_helper(m, m.rel, e) + self.util_methods_helper(m.rel, e, m.z, True, False) + self.equal_at_points_helper(m, m.rel, e, [(-1.5,), (0.8,)]) + self.oa_cuts_helper(m, m.rel, e) + self.add_cuts_helper(m, m.rel, e) + self.pw_helper(m, m.rel, e) + self.changing_bounds_helper(m, m.rel, e) + self.infinite_bounds_helper(m, m.rel) + self.large_bounds_helper(m, m.rel) + self.small_coef_helper(m, m.rel, e) + self.original_constraint_helper(m, m.rel, e) + self.nonlinear_relaxation_helper(m, m.rel, e) + self.relaxation_side_helper(m, m.rel, e, check_nonlinear_relaxation=True) + self.deviation_helper(m.rel, e) + + def test_exp_relaxation(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.PWUnivariateRelaxation() + e = pe.exp(m.x) + m.rel.build( + x=m.x, aux_var=m.z, shape=coramin.utils.FunctionShape.CONVEX, f_x_expr=e + ) + self.options_switching_helper(m.rel) + self.valid_relaxation_helper(m, m.rel, e) + self.util_methods_helper(m.rel, e, m.z, True, False) + self.equal_at_points_helper(m, m.rel, e, [(-1.5,), (0.8,)]) + self.oa_cuts_helper(m, m.rel, e) + self.add_cuts_helper(m, m.rel, e) + self.pw_helper(m, m.rel, e) + self.changing_bounds_helper(m, m.rel, e) + self.infinite_bounds_helper(m, m.rel) + self.large_bounds_helper(m, m.rel) + self.small_coef_helper(m, m.rel, e) + self.original_constraint_helper(m, m.rel, e) + self.nonlinear_relaxation_helper(m, m.rel, e) + self.relaxation_side_helper(m, m.rel, e, check_nonlinear_relaxation=True) + self.deviation_helper(m.rel, e) + + def test_log_relaxation(self): + m = self.get_base_pyomo_model(xlb=0.1, xub=2.5) + m.rel = coramin.relaxations.PWUnivariateRelaxation() + e = pe.log(m.x) + m.rel.build( + x=m.x, aux_var=m.z, shape=coramin.utils.FunctionShape.CONCAVE, f_x_expr=e + ) + self.options_switching_helper(m.rel) + self.valid_relaxation_helper(m, m.rel, e) + self.util_methods_helper(m.rel, e, m.z, False, True) + self.equal_at_points_helper(m, m.rel, e, [(0.1,), (2.5,)]) + self.oa_cuts_helper(m, m.rel, e) + self.add_cuts_helper(m, m.rel, e) + self.pw_helper(m, m.rel, e) + self.changing_bounds_helper(m, m.rel, e) + self.infinite_bounds_helper(m, m.rel) + m.rel.large_coef = 1e3 + self.large_bounds_helper(m, m.rel, lb=1e-4, ub=1e-3) + m.rel.large_coef = 1e5 + self.small_coef_helper(m, m.rel, e) + self.original_constraint_helper(m, m.rel, e) + self.nonlinear_relaxation_helper(m, m.rel, e) + self.relaxation_side_helper(m, m.rel, e, check_nonlinear_relaxation=True) + self.deviation_helper(m.rel, e) + + def test_univariate_convex_relaxation(self): + m = self.get_base_pyomo_model(xlb=0.1, xub=2.5) + m.rel = coramin.relaxations.PWUnivariateRelaxation() + e = m.x * pe.log(m.x) + m.rel.build( + x=m.x, aux_var=m.z, shape=coramin.utils.FunctionShape.CONVEX, f_x_expr=e + ) + self.options_switching_helper(m.rel) + self.valid_relaxation_helper(m, m.rel, e) + self.util_methods_helper(m.rel, e, m.z, True, False) + self.equal_at_points_helper(m, m.rel, e, [(0.1,), (2.5,)]) + self.oa_cuts_helper(m, m.rel, e) + self.add_cuts_helper(m, m.rel, e) + self.pw_helper(m, m.rel, e) + self.changing_bounds_helper(m, m.rel, e) + self.infinite_bounds_helper(m, m.rel) + m.rel.large_coef = 0.1 + self.large_bounds_helper(m, m.rel) + m.rel.large_coef = 1e5 + self.small_coef_helper(m, m.rel, e) + self.original_constraint_helper(m, m.rel, e) + self.nonlinear_relaxation_helper(m, m.rel, e) + self.relaxation_side_helper(m, m.rel, e, check_nonlinear_relaxation=True) + self.deviation_helper(m.rel, e) + + def test_cos_relaxation(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.PWCosRelaxation() + m.rel.build(x=m.x, aux_var=m.z) + e = pe.cos(m.x) + self.options_switching_helper(m.rel) + self.valid_relaxation_helper(m, m.rel, e) + self.util_methods_helper(m.rel, e, m.z, False, True) + self.equal_at_points_helper(m, m.rel, e, [(-1.5,), (0.8,)]) + self.oa_cuts_helper(m, m.rel, e) + self.add_cuts_helper(m, m.rel, e) + self.pw_helper(m, m.rel, e) + self.changing_bounds_helper(m, m.rel, e) + self.infinite_bounds_helper(m, m.rel) + self.large_bounds_helper(m, m.rel) + self.small_coef_helper(m, m.rel, e) + self.original_constraint_helper(m, m.rel, e) + self.nonlinear_relaxation_helper(m, m.rel, e) + self.relaxation_side_helper(m, m.rel, e, check_nonlinear_relaxation=True) + self.deviation_helper(m.rel, e) + + def test_sin_relaxation(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.PWSinRelaxation() + m.rel.build(x=m.x, aux_var=m.z) + e = pe.sin(m.x) + self.valid_relaxation_helper(m, m.rel, e) + self.util_methods_helper(m.rel, e, m.z, False, False) + self.equal_at_points_helper(m, m.rel, e, [(-1.5,), (0.8,)]) + self.oa_cuts_helper(m, m.rel, e) + self.add_cuts_helper(m, m.rel, e) + self.pw_helper(m, m.rel, e) + self.changing_bounds_helper(m, m.rel, e) + self.infinite_bounds_helper(m, m.rel) + self.large_bounds_helper(m, m.rel) + self.small_coef_helper(m, m.rel, e) + self.original_constraint_helper(m, m.rel, e) + self.nonlinear_relaxation_helper(m, m.rel, e) + self.relaxation_side_helper(m, m.rel, e, check_nonlinear_relaxation=True) + self.deviation_helper(m.rel, e) + + def test_atan_relaxation(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.PWArctanRelaxation() + m.rel.build(x=m.x, aux_var=m.z) + e = pe.atan(m.x) + self.valid_relaxation_helper(m, m.rel, e) + self.util_methods_helper(m.rel, e, m.z, False, False) + self.equal_at_points_helper(m, m.rel, e, [(-1.5,), (0.8,)]) + self.oa_cuts_helper(m, m.rel, e) + self.add_cuts_helper(m, m.rel, e) + self.pw_helper(m, m.rel, e) + self.changing_bounds_helper(m, m.rel, e) + self.infinite_bounds_helper(m, m.rel) + m.rel.large_coef = 0.1 + self.large_bounds_helper(m, m.rel) + m.rel.large_coef = 1e5 + self.small_coef_helper(m, m.rel, e) + self.original_constraint_helper(m, m.rel, e) + self.nonlinear_relaxation_helper(m, m.rel, e) + self.relaxation_side_helper(m, m.rel, e, check_nonlinear_relaxation=True) + self.deviation_helper(m.rel, e) + + def test_bilinear_relaxation(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.PWMcCormickRelaxation() + m.rel.build(x1=m.x, x2=m.y, aux_var=m.z) + e = m.x * m.y + self.valid_relaxation_helper(m, m.rel, e) + self.util_methods_helper(m.rel, e, m.z, False, False) + self.equal_at_points_helper( + m, m.rel, e, [(-1.5, -2), (0.8, 1), (-1.5, 1), (0.8, -2)] + ) + self.oa_cuts_helper(m, m.rel, e) + self.add_cuts_helper(m, m.rel, e) + self.pw_helper(m, m.rel, e) + self.changing_bounds_helper(m, m.rel, e, num_points=5) + self.infinite_bounds_helper(m, m.rel) + self.large_bounds_helper(m, m.rel, lb=-1e6, ub=1e6) + self.small_coef_helper(m, m.rel, e) + self.original_constraint_helper(m, m.rel, e) + with self.assertRaisesRegex( + ValueError, + "Relaxations of type do not support relaxations that are not linear.", + ): + self.nonlinear_relaxation_helper(m, m.rel, e) + self.relaxation_side_helper(m, m.rel, e, check_nonlinear_relaxation=False) + self.deviation_helper(m.rel, e) + + def test_multivariate_convex(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.MultivariateRelaxation() + m.rel.build( + aux_var=m.z, shape=coramin.FunctionShape.CONVEX, f_x_expr=m.x**2 + m.y**2 + ) + e = m.x**2 + m.y**2 + self.options_switching_helper(m.rel) + self.valid_relaxation_helper(m, m.rel, e, 10, True, False) + self.util_methods_helper(m.rel, e, m.z, True, False, True, False) + with self.assertRaises(ValueError): + m.rel.relaxation_side = coramin.RelaxationSide.OVER + with self.assertRaises(ValueError): + m.rel.relaxation_side = coramin.RelaxationSide.BOTH + self.equal_at_points_helper( + m, m.rel, e, [(-1.5, -2), (0.8, 1)], True, False, True + ) + self.oa_cuts_helper(m, m.rel, e, 30, True, False) + self.add_cuts_helper(m, m.rel, e, 30, True, False) + self.changing_bounds_helper(m, m.rel, e, 5, True, False) + self.infinite_bounds_helper(m, m.rel) + self.large_bounds_helper(m, m.rel) + self.small_coef_helper(m, m.rel, e, 30, True, False) + self.original_constraint_helper(m, m.rel, e, 15, True, False) + self.nonlinear_relaxation_helper(m, m.rel, e, 15, True, False) + self.deviation_helper(m.rel, e, True, False) + + def test_multivariate_concave(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.MultivariateRelaxation() + m.rel.build( + aux_var=m.z, shape=coramin.FunctionShape.CONCAVE, f_x_expr=-m.x**2 - m.y**2 + ) + e = -m.x**2 - m.y**2 + self.valid_relaxation_helper(m, m.rel, e, 10, False, True) + self.util_methods_helper(m.rel, e, m.z, False, True, False, True) + with self.assertRaises(ValueError): + m.rel.relaxation_side = coramin.RelaxationSide.UNDER + with self.assertRaises(ValueError): + m.rel.relaxation_side = coramin.RelaxationSide.BOTH + self.equal_at_points_helper( + m, m.rel, e, [(-1.5, -2), (0.8, 1)], False, True, True + ) + self.oa_cuts_helper(m, m.rel, e, 30, False, True) + self.add_cuts_helper(m, m.rel, e, 30, False, True) + self.changing_bounds_helper(m, m.rel, e, 5, False, True) + self.infinite_bounds_helper(m, m.rel) + self.large_bounds_helper(m, m.rel) + self.small_coef_helper(m, m.rel, e, 30, False, True) + self.original_constraint_helper(m, m.rel, e, 15, False, True) + self.nonlinear_relaxation_helper(m, m.rel, e, 15, False, True) + self.deviation_helper(m.rel, e, False, True) + + def test_alpha_bb1(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.AlphaBBRelaxation() + m.rel.build( + aux_var=m.z, + f_x_expr=m.x * m.y, + relaxation_side=coramin.RelaxationSide.UNDER, + eigenvalue_opt=appsi.solvers.Gurobi(), + ) + e = m.x * m.y + self.options_switching_helper(m.rel) + self.valid_relaxation_helper(m, m.rel, e, 10, True, False) + self.util_methods_helper(m.rel, e, m.z, False, False, True, False) + with self.assertRaises(ValueError): + m.rel.relaxation_side = coramin.RelaxationSide.OVER + with self.assertRaises(ValueError): + m.rel.relaxation_side = coramin.RelaxationSide.BOTH + self.equal_at_points_helper( + m, m.rel, e, [(-1.5, -2), (0.8, 1)], True, False, True + ) + self.oa_cuts_helper(m, m.rel, e, 30, True, False, False) + self.add_cuts_helper(m, m.rel, e, 30, True, False, False) + self.infinite_bounds_helper(m, m.rel) + self.large_bounds_helper(m, m.rel) + self.small_coef_helper(m, m.rel, e, 30, True, False) + self.original_constraint_helper(m, m.rel, e, 15, True, False) + self.deviation_helper(m.rel, e, True, False) + + def test_alpha_bb2(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.AlphaBBRelaxation() + m.rel.build( + aux_var=m.z, + f_x_expr=-m.x**2 - m.y**2, + relaxation_side=coramin.RelaxationSide.UNDER, + eigenvalue_opt=appsi.solvers.Gurobi(), + ) + e = -m.x**2 - m.y**2 + self.valid_relaxation_helper(m, m.rel, e, 10, True, False) + self.util_methods_helper(m.rel, e, m.z, False, True, True, False) + with self.assertRaises(ValueError): + m.rel.relaxation_side = coramin.RelaxationSide.OVER + with self.assertRaises(ValueError): + m.rel.relaxation_side = coramin.RelaxationSide.BOTH + self.equal_at_points_helper( + m, m.rel, e, [(-1.5, -2), (0.8, 1)], True, False, True + ) + self.oa_cuts_helper(m, m.rel, e, 30, True, False, False) + self.add_cuts_helper(m, m.rel, e, 30, True, False, False) + self.changing_bounds_helper(m, m.rel, e, 5, True, False, False) + self.infinite_bounds_helper(m, m.rel) + self.large_bounds_helper(m, m.rel) + self.small_coef_helper(m, m.rel, e, 30, True, False) + self.original_constraint_helper(m, m.rel, e, 15, True, False) + self.nonlinear_relaxation_helper(m, m.rel, e, 15, True, False, False) + self.deviation_helper(m.rel, e, True, False) + + def test_alpha_bb3(self): + m = self.get_base_pyomo_model() + m.rel = coramin.relaxations.AlphaBBRelaxation() + m.rel.build( + aux_var=m.z, + f_x_expr=m.x**2 + m.y**2, + relaxation_side=coramin.RelaxationSide.UNDER, + eigenvalue_opt=appsi.solvers.Gurobi(), + ) + e = m.x**2 + m.y**2 + self.valid_relaxation_helper(m, m.rel, e, 10, True, False) + self.util_methods_helper(m.rel, e, m.z, True, False, True, False) + with self.assertRaises(ValueError): + m.rel.relaxation_side = coramin.RelaxationSide.OVER + with self.assertRaises(ValueError): + m.rel.relaxation_side = coramin.RelaxationSide.BOTH + self.equal_at_points_helper( + m, m.rel, e, [(-1.5, -2), (0.8, 1)], True, False, True + ) + self.oa_cuts_helper(m, m.rel, e, 30, True, False, True) + self.add_cuts_helper(m, m.rel, e, 30, True, False, True) + self.changing_bounds_helper(m, m.rel, e, 5, True, False, True) + self.infinite_bounds_helper(m, m.rel) + self.large_bounds_helper(m, m.rel) + self.small_coef_helper(m, m.rel, e, 30, True, False) + self.original_constraint_helper(m, m.rel, e, 15, True, False) + self.nonlinear_relaxation_helper(m, m.rel, e, 15, True, False, True) + self.deviation_helper(m.rel, e, True, False) diff --git a/pyomo/contrib/coramin/relaxations/tests/test_relaxations_base.py b/pyomo/contrib/coramin/relaxations/tests/test_relaxations_base.py new file mode 100644 index 00000000000..4741df07f2d --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/tests/test_relaxations_base.py @@ -0,0 +1,169 @@ +from pyomo.common import unittest +import pyomo.environ as pe +from pyomo.opt import assert_optimal_termination +from pyomo.contrib import coramin +from pyomo.core.base.var import SimpleVar + + +gurobi_available = pe.SolverFactory('appsi_gurobi').available() + + +""" +Things to test +- relaxations are valid under all possible conditions + - continuous relaxations + - nonlinear relaxations + - pw relaxations +- infinite variable bounds +- tightness of relaxations + - as variable bounds improve, the relaxations should get tighter + - at variable bounds, relaxations should be exact + - cuts improve relaxation +- large coef +- small coef +- safety tol +- relaxation side +- modifications to relaxations +- rebuild + - if things don't need removed, don't remove them + - rebuild with original constraint +- cloning +""" + + +class TestBaseRelaxation(unittest.TestCase): + @unittest.skipUnless(gurobi_available, 'gurboi is not available') + def test_push_and_pop_oa_points(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, 1)) + m.y = pe.Var() + m.rel = coramin.relaxations.PWXSquaredRelaxation() + m.rel.build(x=m.x, aux_var=m.y) + m.obj = pe.Objective(expr=m.y) + + opt = pe.SolverFactory('appsi_gurobi') + + res = opt.solve(m) + assert_optimal_termination(res) + self.assertAlmostEqual(m.x.value, -0.5) + self.assertAlmostEqual(m.y.value, -2) + + m.x.value = -1 + m.rel.add_cut(keep_cut=True) + res = opt.solve(m) + assert_optimal_termination(res) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, -1) + + m.rel.push_oa_points() + m.rel.rebuild() + res = opt.solve(m) + assert_optimal_termination(res) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, -1) + + m.rel.clear_oa_points() + m.rel.rebuild() + res = opt.solve(m) + assert_optimal_termination(res) + self.assertAlmostEqual(m.x.value, -0.5) + self.assertAlmostEqual(m.y.value, -2) + + m.x.value = -0.5 + m.rel.add_cut(keep_cut=True) + res = opt.solve(m) + assert_optimal_termination(res) + self.assertAlmostEqual(m.x.value, 0.25) + self.assertAlmostEqual(m.y.value, -0.5) + + m.rel.pop_oa_points() + m.rel.rebuild() + res = opt.solve(m) + assert_optimal_termination(res) + self.assertAlmostEqual(m.x.value, 0) + self.assertAlmostEqual(m.y.value, -1) + + def test_push_oa_points_with_key(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.c = coramin.relaxations.PWXSquaredRelaxation() + m.c.build(x=m.x, aux_var=m.y) + m.c.add_oa_point(pe.ComponentMap([(m.x, 0)])) + self.assertEqual(list(m.c._oa_points.keys()), [(-1,), (1,), (0,)]) + m.c.push_oa_points(key='first key') + m.c.add_oa_point(pe.ComponentMap([(m.x, 0.5)])) + self.assertEqual(list(m.c._oa_points.keys()), [(-1,), (1,), (0,), (0.5,)]) + m.c.push_oa_points() + m.c.add_oa_point(pe.ComponentMap([(m.x, -0.5)])) + self.assertEqual( + list(m.c._oa_points.keys()), [(-1,), (1,), (0,), (0.5,), (-0.5,)] + ) + m.c.push_oa_points(key='second key') + m.c.pop_oa_points(key='first key') + self.assertEqual(list(m.c._oa_points.keys()), [(-1,), (1,), (0,)]) + m.c.pop_oa_points() + self.assertEqual(list(m.c._oa_points.keys()), [(-1,), (1,), (0,), (0.5,)]) + m.c.pop_oa_points(key='second key') + self.assertEqual( + list(m.c._oa_points.keys()), [(-1,), (1,), (0,), (0.5,), (-0.5,)] + ) + + def test_push_and_pop_partitions(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-2, 1)) + m.y = pe.Var() + m.rel = coramin.relaxations.PWXSquaredRelaxation() + m.rel.build(x=m.x, aux_var=m.y) + m.obj = pe.Objective(expr=m.y) + self.assertEqual(m.rel._partitions[m.x], [-2, 1]) + + m.rel.add_partition_point(-1) + m.rel.rebuild() + self.assertEqual(m.rel._partitions[m.x], [-2, -1, 1]) + + m.rel.push_partitions() + m.rel.rebuild() + self.assertEqual(m.rel._partitions[m.x], [-2, -1, 1]) + + m.rel.clear_partitions() + m.rel.rebuild() + self.assertEqual(m.rel._partitions[m.x], [-2, 1]) + + m.rel.add_partition_point(-0.5) + m.rel.rebuild() + self.assertEqual(m.rel._partitions[m.x], [-2, -0.5, 1]) + + m.rel.pop_partitions() + m.rel.rebuild() + self.assertEqual(m.rel._partitions[m.x], [-2, -1, 1]) + + def test_push_and_pop_partitions_2(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.c = coramin.relaxations.PWXSquaredRelaxation() + m.c.build(x=m.x, aux_var=m.y) + self.assertEqual(m.c._partitions, pe.ComponentMap([(m.x, [-1, 1])])) + m.x.setlb(0) + m.c.rebuild() + self.assertEqual(m.c._partitions, pe.ComponentMap([(m.x, [0, 1])])) + m.x.setlb(-1) + m.c.rebuild() + self.assertEqual(m.c._partitions, pe.ComponentMap([(m.x, [-1, 1])])) + m.x.value = 0.5 + m.c.add_partition_point() + m.c.rebuild() + self.assertEqual(m.c._partitions, pe.ComponentMap([(m.x, [-1, 0.5, 1])])) + m.x.setlb(0) + m.c.rebuild() + self.assertEqual(m.c._partitions, pe.ComponentMap([(m.x, [0, 0.5, 1])])) + m.x.setlb(-1) + m.c.rebuild() + self.assertEqual(m.c._partitions, pe.ComponentMap([(m.x, [-1, 0.5, 1])])) + m.x.setub(0) + m.c.rebuild() + self.assertEqual(m.c._partitions, pe.ComponentMap([(m.x, [-1, 0])])) + m.x.setub(1) + m.c.rebuild() + self.assertEqual(m.c._partitions, pe.ComponentMap([(m.x, [-1, 1])])) diff --git a/pyomo/contrib/coramin/relaxations/tests/test_univariate_relaxations.py b/pyomo/contrib/coramin/relaxations/tests/test_univariate_relaxations.py new file mode 100644 index 00000000000..c96dd032c9b --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/tests/test_univariate_relaxations.py @@ -0,0 +1,384 @@ +from pyomo.common import unittest +import math +import pyomo.environ as pe +from pyomo.contrib import coramin +from pyomo.common.dependencies import numpy as np +from pyomo.contrib.coramin.relaxations.segments import compute_k_segment_points +from pyomo.contrib import appsi + + +gurobi_available = appsi.solvers.Gurobi().available() + + +@unittest.skipUnless(gurobi_available, 'Gurobi is not available') +class TestUnivariateExp(unittest.TestCase): + @classmethod + def setUpClass(cls): + model = pe.ConcreteModel() + cls.model = model + model.y = pe.Var() + model.x = pe.Var(bounds=(-1.5, 1.5)) + + model.obj = pe.Objective(expr=model.y, sense=pe.maximize) + model.pw_exp = coramin.relaxations.PWUnivariateRelaxation() + model.pw_exp.build( + x=model.x, + aux_var=model.y, + pw_repn='INC', + shape=coramin.utils.FunctionShape.CONVEX, + relaxation_side=coramin.utils.RelaxationSide.BOTH, + f_x_expr=pe.exp(model.x), + ) + model.pw_exp.add_partition_point(-0.5) + model.pw_exp.add_partition_point(0.5) + model.pw_exp.rebuild() + + @classmethod + def tearDownClass(cls): + pass + + def test_exp_ub(self): + model = self.model.clone() + + solver = pe.SolverFactory('gurobi_direct') + solver.solve(model) + self.assertAlmostEqual(pe.value(model.y), math.exp(1.5), 4) + + def test_exp_mid(self): + model = self.model.clone() + model.x_con = pe.Constraint(expr=model.x <= 0.3) + + solver = pe.SolverFactory('gurobi_direct') + solver.solve(model) + self.assertAlmostEqual(pe.value(model.y), 1.44, 3) + + def test_exp_lb(self): + model = self.model.clone() + model.obj.sense = pe.minimize + + solver = pe.SolverFactory('gurobi_direct') + solver.solve(model) + self.assertAlmostEqual(pe.value(model.y), math.exp(-1.5), 4) + + +@unittest.skipUnless(gurobi_available, 'Gurobi is not available') +class TestUnivariate(unittest.TestCase): + def helper( + self, + func, + shape, + bounds_list, + relaxation_class, + relaxation_side=coramin.utils.RelaxationSide.BOTH, + ): + for lb, ub in bounds_list: + num_segments_list = [1, 2, 3] + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(lb, ub)) + m.aux = pe.Var() + if relaxation_class is coramin.relaxations.PWUnivariateRelaxation: + m.c = coramin.relaxations.PWUnivariateRelaxation() + m.c.build( + x=m.x, + aux_var=m.aux, + relaxation_side=relaxation_side, + shape=shape, + f_x_expr=func(m.x), + ) + else: + m.c = relaxation_class() + m.c.build(x=m.x, aux_var=m.aux, relaxation_side=relaxation_side) + m.p = pe.Param(mutable=True, initialize=0) + m.c2 = pe.Constraint(expr=m.x == m.p) + opt = pe.SolverFactory('gurobi_persistent') + for num_segments in num_segments_list: + segment_points = compute_k_segment_points(m.x, num_segments) + m.c.clear_partitions() + for pt in segment_points: + m.c.add_partition_point(pt) + var_values = pe.ComponentMap() + var_values[m.x] = pt + m.c.add_oa_point(var_values=var_values) + m.c.rebuild() + opt.set_instance(m) + for _x in [float(i) for i in np.linspace(lb, ub, 10)]: + m.p.value = _x + opt.remove_constraint(m.c2) + opt.add_constraint(m.c2) + if relaxation_side in { + coramin.utils.RelaxationSide.BOTH, + coramin.utils.RelaxationSide.UNDER, + }: + m.obj = pe.Objective(expr=m.aux) + opt.set_objective(m.obj) + res = opt.solve() + self.assertEqual( + res.solver.termination_condition, + pe.TerminationCondition.optimal, + ) + self.assertLessEqual(m.aux.value, func(_x) + 1e-10) + del m.obj + if relaxation_side in { + coramin.utils.RelaxationSide.BOTH, + coramin.utils.RelaxationSide.OVER, + }: + m.obj = pe.Objective(expr=m.aux, sense=pe.maximize) + opt.set_objective(m.obj) + res = opt.solve() + self.assertEqual( + res.solver.termination_condition, + pe.TerminationCondition.optimal, + ) + self.assertGreaterEqual(m.aux.value, func(_x) - 1e-10) + del m.obj + + def test_exp(self): + self.helper( + func=pe.exp, + shape=coramin.utils.FunctionShape.CONVEX, + bounds_list=[(-1, 1)], + relaxation_class=coramin.relaxations.PWUnivariateRelaxation, + ) + + def test_log(self): + self.helper( + func=pe.log, + shape=coramin.utils.FunctionShape.CONCAVE, + bounds_list=[(0.5, 1.5)], + relaxation_class=coramin.relaxations.PWUnivariateRelaxation, + ) + + def test_quadratic(self): + def quadratic_func(x): + return x**2 + + self.helper( + func=quadratic_func, + shape=None, + bounds_list=[(-1, 2)], + relaxation_class=coramin.relaxations.PWXSquaredRelaxation, + ) + + def test_arctan(self): + self.helper( + func=pe.atan, + shape=None, + bounds_list=[(-1, 1), (-1, 0), (0, 1)], + relaxation_class=coramin.relaxations.PWArctanRelaxation, + ) + self.helper( + func=pe.atan, + shape=None, + bounds_list=[(-0.1, 1)], + relaxation_class=coramin.relaxations.PWArctanRelaxation, + relaxation_side=coramin.utils.RelaxationSide.OVER, + ) + self.helper( + func=pe.atan, + shape=None, + bounds_list=[(-1, 0.1)], + relaxation_class=coramin.relaxations.PWArctanRelaxation, + relaxation_side=coramin.utils.RelaxationSide.UNDER, + ) + + def test_sin(self): + self.helper( + func=pe.sin, + shape=None, + bounds_list=[(-1, 1), (-1, 0), (0, 1)], + relaxation_class=coramin.relaxations.PWSinRelaxation, + ) + self.helper( + func=pe.sin, + shape=None, + bounds_list=[(-0.1, 1)], + relaxation_class=coramin.relaxations.PWSinRelaxation, + relaxation_side=coramin.utils.RelaxationSide.OVER, + ) + self.helper( + func=pe.sin, + shape=None, + bounds_list=[(-1, 0.1)], + relaxation_class=coramin.relaxations.PWSinRelaxation, + relaxation_side=coramin.utils.RelaxationSide.UNDER, + ) + + def test_cos(self): + self.helper( + func=pe.cos, + shape=None, + bounds_list=[(-1, 1)], + relaxation_class=coramin.relaxations.PWCosRelaxation, + ) + + +@unittest.skipUnless(gurobi_available, 'Gurobi is not available') +class TestFeasibility(unittest.TestCase): + def test_univariate_exp(self): + m = pe.ConcreteModel() + m.p = pe.Param(initialize=-1, mutable=True) + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.z = pe.Var(bounds=(0, None)) + m.c = coramin.relaxations.PWUnivariateRelaxation() + m.c.build( + x=m.x, + aux_var=m.y, + relaxation_side=coramin.utils.RelaxationSide.BOTH, + shape=coramin.utils.FunctionShape.CONVEX, + f_x_expr=pe.exp(m.x), + ) + m.c.rebuild() + m.c2 = pe.ConstraintList() + m.c2.add(m.z >= m.y - m.p) + m.c2.add(m.z >= m.p - m.y) + m.obj = pe.Objective(expr=m.z) + opt = pe.SolverFactory('gurobi_direct') + for xval in [-1, -0.5, 0, 0.5, 1]: + pval = math.exp(xval) + m.x.fix(xval) + m.p.value = pval + res = opt.solve(m, tee=False) + self.assertTrue( + res.solver.termination_condition == pe.TerminationCondition.optimal + ) + self.assertAlmostEqual(m.y.value, m.p.value, 6) + + def test_pw_exp(self): + m = pe.ConcreteModel() + m.p = pe.Param(initialize=-1, mutable=True) + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.z = pe.Var(bounds=(0, None)) + m.c = coramin.relaxations.PWUnivariateRelaxation() + m.c.build( + x=m.x, + aux_var=m.y, + relaxation_side=coramin.utils.RelaxationSide.BOTH, + shape=coramin.utils.FunctionShape.CONVEX, + f_x_expr=pe.exp(m.x), + ) + m.c.add_partition_point(-0.25) + m.c.add_partition_point(0.25) + m.c.rebuild() + m.c2 = pe.ConstraintList() + m.c2.add(m.z >= m.y - m.p) + m.c2.add(m.z >= m.p - m.y) + m.obj = pe.Objective(expr=m.z) + opt = pe.SolverFactory('gurobi_direct') + for xval in [-1, -0.5, 0, 0.5, 1]: + pval = math.exp(xval) + m.x.fix(xval) + m.p.value = pval + res = opt.solve(m, tee=False) + self.assertTrue( + res.solver.termination_condition == pe.TerminationCondition.optimal + ) + self.assertAlmostEqual(m.y.value, m.p.value, 6) + + def test_univariate_log(self): + m = pe.ConcreteModel() + m.p = pe.Param(initialize=-1, mutable=True) + m.x = pe.Var(bounds=(0.5, 1.5)) + m.y = pe.Var() + m.z = pe.Var(bounds=(0, None)) + m.c = coramin.relaxations.PWUnivariateRelaxation() + m.c.build( + x=m.x, + aux_var=m.y, + relaxation_side=coramin.utils.RelaxationSide.BOTH, + shape=coramin.utils.FunctionShape.CONCAVE, + f_x_expr=pe.log(m.x), + ) + m.c.rebuild() + m.c2 = pe.ConstraintList() + m.c2.add(m.z >= m.y - m.p) + m.c2.add(m.z >= m.p - m.y) + m.obj = pe.Objective(expr=m.z) + opt = pe.SolverFactory('gurobi_direct') + for xval in [0.5, 0.75, 1, 1.25, 1.5]: + pval = math.log(xval) + m.x.fix(xval) + m.p.value = pval + res = opt.solve(m, tee=False) + self.assertTrue( + res.solver.termination_condition == pe.TerminationCondition.optimal + ) + self.assertAlmostEqual(m.y.value, m.p.value, 6) + + def test_pw_log(self): + m = pe.ConcreteModel() + m.p = pe.Param(initialize=-1, mutable=True) + m.x = pe.Var(bounds=(0.5, 1.5)) + m.y = pe.Var() + m.z = pe.Var(bounds=(0, None)) + m.c = coramin.relaxations.PWUnivariateRelaxation() + m.c.build( + x=m.x, + aux_var=m.y, + relaxation_side=coramin.utils.RelaxationSide.BOTH, + shape=coramin.utils.FunctionShape.CONCAVE, + f_x_expr=pe.log(m.x), + ) + m.c.add_partition_point(0.9) + m.c.add_partition_point(1.1) + m.c.rebuild() + m.c2 = pe.ConstraintList() + m.c2.add(m.z >= m.y - m.p) + m.c2.add(m.z >= m.p - m.y) + m.obj = pe.Objective(expr=m.z) + opt = pe.SolverFactory('gurobi_direct') + for xval in [0.5, 0.75, 1, 1.25, 1.5]: + pval = math.log(xval) + m.x.fix(xval) + m.p.value = pval + res = opt.solve(m, tee=False) + self.assertTrue( + res.solver.termination_condition == pe.TerminationCondition.optimal + ) + self.assertAlmostEqual(m.y.value, m.p.value, 6) + + def test_x_fixed(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.x.setlb(0) + m.x.setub(0) + m.c = coramin.relaxations.PWUnivariateRelaxation() + m.c.build( + x=m.x, + aux_var=m.y, + relaxation_side=coramin.utils.RelaxationSide.BOTH, + shape=coramin.utils.FunctionShape.CONVEX, + f_x_expr=pe.exp(m.x), + ) + m.obj = pe.Objective(expr=m.y) + opt = pe.SolverFactory('appsi_gurobi') + res = opt.solve(m) + self.assertAlmostEqual(m.y.value, 1) + m.obj.sense = pe.maximize + res = opt.solve(m) + self.assertAlmostEqual(m.y.value, 1) + + def test_x_sq(self): + m = pe.ConcreteModel() + m.p = pe.Param(initialize=-1, mutable=True) + m.x = pe.Var(bounds=(-1, 1)) + m.y = pe.Var() + m.z = pe.Var(bounds=(0, None)) + m.c = coramin.relaxations.PWXSquaredRelaxation() + m.c.build(x=m.x, aux_var=m.y, relaxation_side=coramin.utils.RelaxationSide.BOTH) + m.c2 = pe.ConstraintList() + m.c2.add(m.z >= m.y - m.p) + m.c2.add(m.z >= m.p - m.y) + m.obj = pe.Objective(expr=m.z) + opt = pe.SolverFactory('appsi_gurobi') + for xval in [-1, -0.5, 0, 0.5, 1]: + pval = xval**2 + m.x.fix(xval) + m.p.value = pval + res = opt.solve(m, tee=False) + self.assertTrue( + res.solver.termination_condition == pe.TerminationCondition.optimal + ) + self.assertAlmostEqual(m.y.value, m.p.value, 6) diff --git a/pyomo/contrib/coramin/relaxations/univariate.py b/pyomo/contrib/coramin/relaxations/univariate.py new file mode 100644 index 00000000000..17da092e041 --- /dev/null +++ b/pyomo/contrib/coramin/relaxations/univariate.py @@ -0,0 +1,1334 @@ +import pyomo.environ as pyo +from pyomo.contrib.coramin.utils.coramin_enums import RelaxationSide, FunctionShape +from .relaxations_base import BasePWRelaxationData, _check_cut +from .custom_block import declare_custom_block +from pyomo.common.dependencies import numpy as np +import math +import scipy.optimize +from ._utils import check_var_pts, _get_bnds_list, _get_bnds_tuple +from pyomo.core.base.param import ScalarParam, IndexedParam +from pyomo.core.base.constraint import ScalarConstraint, IndexedConstraint +from pyomo.core.expr.numeric_expr import LinearExpression +import logging +from typing import Optional, Union, Sequence +from pyomo.core.expr.calculus.diff_with_pyomo import reverse_sd + +logger = logging.getLogger(__name__) +pe = pyo + + +def _sin_overestimator_fn(x, LB): + return np.sin(x) + np.cos(x) * (LB - x) - np.sin(LB) + + +def _sin_underestimator_fn(x, UB): + return np.sin(x) + np.cos(-x) * (UB - x) - np.sin(UB) + + +def _compute_sine_overestimator_tangent_point(vlb): + assert vlb < 0 + tangent_point, res = scipy.optimize.bisect( + f=_sin_overestimator_fn, + a=0, + b=math.pi / 2, + args=(vlb,), + full_output=True, + disp=False, + ) + if res.converged: + tangent_point = float(tangent_point) + slope = float(np.cos(tangent_point)) + intercept = float(np.sin(vlb) - slope * vlb) + return tangent_point, slope, intercept + else: + raise RuntimeError( + 'Unable to build relaxation for sin(x)\nBisect info: ' + str(res) + ) + + +def _compute_sine_underestimator_tangent_point(vub): + assert vub > 0 + tangent_point, res = scipy.optimize.bisect( + f=_sin_underestimator_fn, + a=-math.pi / 2, + b=0, + args=(vub,), + full_output=True, + disp=False, + ) + if res.converged: + tangent_point = float(tangent_point) + slope = float(np.cos(-tangent_point)) + intercept = float(np.sin(vub) - slope * vub) + return tangent_point, slope, intercept + else: + raise RuntimeError( + 'Unable to build relaxation for sin(x)\nBisect info: ' + str(res) + ) + + +def _atan_overestimator_fn(x, LB): + return (1 + x**2) * (np.arctan(x) - np.arctan(LB)) - x + LB + + +def _atan_underestimator_fn(x, UB): + return (1 + x**2) * (np.arctan(x) - np.arctan(UB)) - x + UB + + +def _compute_arctan_overestimator_tangent_point(vlb): + assert vlb < 0 + tangent_point, res = scipy.optimize.bisect( + f=_atan_overestimator_fn, + a=0, + b=abs(vlb), + args=(vlb,), + full_output=True, + disp=False, + ) + if res.converged: + tangent_point = float(tangent_point) + slope = 1 / (1 + tangent_point**2) + intercept = float(np.arctan(vlb) - slope * vlb) + return tangent_point, slope, intercept + else: + raise RuntimeError( + 'Unable to build relaxation for arctan(x)\nBisect info: ' + str(res) + ) + + +def _compute_arctan_underestimator_tangent_point(vub): + assert vub > 0 + tangent_point, res = scipy.optimize.bisect( + f=_atan_underestimator_fn, + a=-vub, + b=0, + args=(vub,), + full_output=True, + disp=False, + ) + if res.converged: + tangent_point = float(tangent_point) + slope = 1 / (1 + tangent_point**2) + intercept = float(np.arctan(vub) - slope * vub) + return tangent_point, slope, intercept + else: + raise RuntimeError( + 'Unable to build relaxation for arctan(x)\nBisect info: ' + str(res) + ) + + +class _FxExpr(object): + def __init__(self, expr, x): + self._expr = expr + self._x = x + self._deriv = reverse_sd(expr)[x] + + def eval(self, _xval): + _xval = pyo.value(_xval) + orig_xval = self._x.value + self._x.value = _xval + res = pyo.value(self._expr) + self._x.set_value(orig_xval, skip_validation=True) + return res + + def deriv(self, _xval): + _xval = pyo.value(_xval) + orig_xval = self._x.value + self._x.value = _xval + res = pyo.value(self._deriv) + self._x.set_value(orig_xval, skip_validation=True) + return res + + def __call__(self, _xval): + return self.eval(_xval) + + +def _func_wrapper(obj): + def _func(m, val): + return obj(val) + + return _func + + +def _pw_univariate_relaxation( + b, + x, + w, + x_pts, + f_x_expr, + pw_repn='INC', + shape=FunctionShape.UNKNOWN, + relaxation_side=RelaxationSide.BOTH, + large_eval_tol=math.inf, + safety_tol=0, +): + """ + This function creates piecewise envelopes to relax "w=f(x)" where f(x) is univariate and either convex over the + entire domain of x or concave over the entire domain of x. + + Parameters + ---------- + b: pyo.Block + x: pyo.Var + The "x" variable in f(x) + w: pyo.Var + The "w" variable that is replacing f(x) + x_pts: Sequence[float] + A list of floating point numbers to define the points over which the piecewise representation will generated. + This list must be ordered, and it is expected that the first point (x_pts[0]) is equal to x.lb and the last + point (x_pts[-1]) is equal to x.ub + f_x_expr: pyomo expression + An expression for f(x) + pw_repn: str + This must be one of the valid strings for the piecewise representation to use (directly from the Piecewise + component). Use help(Piecewise) to learn more. + shape: FunctionShape + Specify the shape of the function. Valid values are minlp.FunctionShape.CONVEX or minlp.FunctionShape.CONCAVE + relaxation_side: RelaxationSide + Provide the desired side for the relaxation (OVER, UNDER, or BOTH) + large_eval_tol: float + To avoid numerical problems, if f_x_expr or its derivative evaluates to a value larger than large_eval_tol, + at a point in x_pts, then that point is skipped. + """ + assert shape in {FunctionShape.CONCAVE, FunctionShape.CONVEX} + assert relaxation_side in {RelaxationSide.UNDER, RelaxationSide.OVER} + if relaxation_side == RelaxationSide.UNDER: + assert shape == FunctionShape.CONCAVE + else: + assert shape == FunctionShape.CONVEX + + _eval = _FxExpr(expr=f_x_expr, x=x) + xlb = x_pts[0] + xub = x_pts[-1] + + check_var_pts(x, x_pts) + + if x.is_fixed(): + b.x_fixed_con = pyo.Constraint(expr=w == _eval(x.value)) + elif xlb == xub: + b.x_fixed_con = pyo.Constraint(expr=w == _eval(x.lb)) + else: + # Do the non-convex piecewise portion if shape=CONCAVE and relaxation_side=Under/BOTH + # or if shape=CONVEX and relaxation_side=Over/BOTH + pw_constr_type = None + if shape == FunctionShape.CONVEX and relaxation_side in { + RelaxationSide.OVER, + RelaxationSide.BOTH, + }: + pw_constr_type = 'UB' + _eval = _FxExpr(expr=f_x_expr + safety_tol, x=x) + if shape == FunctionShape.CONCAVE and relaxation_side in { + RelaxationSide.UNDER, + RelaxationSide.BOTH, + }: + pw_constr_type = 'LB' + _eval = _FxExpr(expr=f_x_expr - safety_tol, x=x) + + if pw_constr_type is not None: + # Build the piecewise side of the envelope + if x_pts[0] > -math.inf and x_pts[-1] < math.inf: + tmp_pts = list() + for _pt in x_pts: + try: + f = _eval(_pt) + if abs(f) >= large_eval_tol: + logger.warning( + f'Skipping pt {_pt} for var {str(x)} because |{str(f_x_expr)}| ' + f'evaluated at {_pt} is larger than {large_eval_tol}' + ) + continue + tmp_pts.append(_pt) + except (ZeroDivisionError, ValueError, OverflowError): + pass + if ( + len(tmp_pts) >= 2 + and tmp_pts[0] == x_pts[0] + and tmp_pts[-1] == x_pts[-1] + ): + b.pw_linear_under_over = pyo.Piecewise( + w, + x, + pw_pts=tmp_pts, + pw_repn=pw_repn, + pw_constr_type=pw_constr_type, + f_rule=_func_wrapper(_eval), + ) + + +def pw_sin_relaxation( + b, x, w, x_pts, relaxation_side=RelaxationSide.BOTH, safety_tol=1e-10 +): + """ + This function creates piecewise relaxations to relax "w=sin(x)" for -pi/2 <= x <= pi/2. + + Parameters + ---------- + b: pyo.Block + x: pyomo.core.base.var.SimpleVar or pyomo.core.base.var._GeneralVarData + The "x" variable in sin(x). The lower bound on x must greater than or equal to + -pi/2 and the upper bound on x must be less than or equal to pi/2. + w: pyomo.core.base.var.SimpleVar or pyomo.core.base.var._GeneralVarData + The auxiliary variable replacing sin(x) + x_pts: Sequence[float] + A list of floating point numbers to define the points over which the piecewise + representation will be generated. This list must be ordered, and it is expected + that the first point (x_pts[0]) is equal to x.lb and the last point (x_pts[-1]) + is equal to x.ub + relaxation_side: minlp.RelaxationSide + Provide the desired side for the relaxation (OVER, UNDER, or BOTH) + safety_tol: float + amount to lift the overestimator or drop the underestimator. This is used to ensure none of the feasible + region is cut off by error in computing the over and under estimators. + """ + check_var_pts(x, x_pts) + expr = pyo.sin(x) + + xlb = x_pts[0] + xub = x_pts[-1] + + if x.is_fixed() or xlb == xub: + b.x_fixed_con = pyo.Constraint(expr=w == (pyo.value(expr))) + return + + if xlb < -np.pi / 2.0: + return + + if xub > np.pi / 2.0: + return + + (OE_tangent_x, OE_tangent_slope, OE_tangent_intercept) = ( + _compute_sine_overestimator_tangent_point(xlb) + ) + ( + under_estimator_tangent_x, + under_estimator_tangent_slope, + under_estimator_tangent_intercept, + ) = _compute_sine_underestimator_tangent_point(xub) + non_piecewise_overestimators_pts = [] + non_piecewise_underestimator_pts = [] + + if relaxation_side == RelaxationSide.OVER: + if OE_tangent_x < xub: + new_x_pts = [i for i in x_pts if i < OE_tangent_x] + new_x_pts.append(xub) + non_piecewise_overestimators_pts = [OE_tangent_x] + non_piecewise_overestimators_pts.extend( + i for i in x_pts if i > OE_tangent_x + ) + x_pts = new_x_pts + elif relaxation_side == RelaxationSide.UNDER: + if under_estimator_tangent_x > xlb: + new_x_pts = [xlb] + new_x_pts.extend(i for i in x_pts if i > under_estimator_tangent_x) + non_piecewise_underestimator_pts = [ + i for i in x_pts if i < under_estimator_tangent_x + ] + non_piecewise_underestimator_pts.append(under_estimator_tangent_x) + x_pts = new_x_pts + + b.non_piecewise_overestimators = pyo.ConstraintList() + b.non_piecewise_underestimators = pyo.ConstraintList() + for pt in non_piecewise_overestimators_pts: + b.non_piecewise_overestimators.add( + w <= math.sin(pt) + safety_tol + (x - pt) * math.cos(pt) + ) + for pt in non_piecewise_underestimator_pts: + b.non_piecewise_underestimators.add( + w >= math.sin(pt) - safety_tol + (x - pt) * math.cos(pt) + ) + + intervals = [] + for i in range(len(x_pts) - 1): + intervals.append((x_pts[i], x_pts[i + 1])) + + b.interval_set = pyo.Set(initialize=range(len(intervals)), ordered=True) + b.x = pyo.Var(b.interval_set) + b.w = pyo.Var(b.interval_set) + if len(intervals) == 1: + b.lam = pyo.Param(b.interval_set, mutable=True) + b.lam[0].value = 1.0 + else: + b.lam = pyo.Var(b.interval_set, within=pyo.Binary) + b.x_lb = pyo.ConstraintList() + b.x_ub = pyo.ConstraintList() + b.x_sum = pyo.Constraint(expr=x == sum(b.x[i] for i in b.interval_set)) + b.w_sum = pyo.Constraint(expr=w == sum(b.w[i] for i in b.interval_set)) + b.lam_sum = pyo.Constraint(expr=sum(b.lam[i] for i in b.interval_set) == 1) + b.overestimators = pyo.ConstraintList() + b.underestimators = pyo.ConstraintList() + + for i, tup in enumerate(intervals): + x0 = tup[0] + x1 = tup[1] + + b.x_lb.add(x0 * b.lam[i] <= b.x[i]) + b.x_ub.add(b.x[i] <= x1 * b.lam[i]) + + # Overestimators + if relaxation_side in {RelaxationSide.OVER, RelaxationSide.BOTH}: + if x0 < 0 and x1 <= 0: + slope = (math.sin(x1) - math.sin(x0)) / (x1 - x0) + intercept = math.sin(x0) - slope * x0 + b.overestimators.add( + b.w[i] <= slope * b.x[i] + (intercept + safety_tol) * b.lam[i] + ) + elif (x0 < 0) and (x1 > 0): + (tangent_x, tangent_slope, tangent_intercept) = ( + _compute_sine_overestimator_tangent_point(x0) + ) + if tangent_x <= x1: + b.overestimators.add( + b.w[i] + <= tangent_slope * b.x[i] + + (tangent_intercept + safety_tol) * b.lam[i] + ) + b.overestimators.add( + b.w[i] + <= math.cos(x1) * b.x[i] + + (math.sin(x1) - x1 * math.cos(x1) + safety_tol) * b.lam[i] + ) + else: + slope = (math.sin(x1) - math.sin(x0)) / (x1 - x0) + intercept = math.sin(x0) - slope * x0 + b.overestimators.add( + b.w[i] <= slope * b.x[i] + (intercept + safety_tol) * b.lam[i] + ) + else: + b.overestimators.add( + b.w[i] + <= math.cos(x0) * b.x[i] + + (math.sin(x0) - x0 * math.cos(x0) + safety_tol) * b.lam[i] + ) + b.overestimators.add( + b.w[i] + <= math.cos(x1) * b.x[i] + + (math.sin(x1) - x1 * math.cos(x1) + safety_tol) * b.lam[i] + ) + + # Underestimators + if relaxation_side in {RelaxationSide.UNDER, RelaxationSide.BOTH}: + if x0 >= 0 and x1 > 0: + slope = (math.sin(x1) - math.sin(x0)) / (x1 - x0) + intercept = math.sin(x0) - slope * x0 + b.underestimators.add( + b.w[i] >= slope * b.x[i] + (intercept - safety_tol) * b.lam[i] + ) + elif (x1 > 0) and (x0 < 0): + (tangent_x, tangent_slope, tangent_intercept) = ( + _compute_sine_underestimator_tangent_point(x1) + ) + if tangent_x >= x0: + b.underestimators.add( + b.w[i] + >= tangent_slope * b.x[i] + + (tangent_intercept - safety_tol) * b.lam[i] + ) + b.underestimators.add( + b.w[i] + >= math.cos(x0) * b.x[i] + + (math.sin(x0) - x0 * math.cos(x0) - safety_tol) * b.lam[i] + ) + else: + slope = (math.sin(x1) - math.sin(x0)) / (x1 - x0) + intercept = math.sin(x0) - slope * x0 + b.underestimators.add( + b.w[i] >= slope * b.x[i] + (intercept - safety_tol) * b.lam[i] + ) + else: + b.underestimators.add( + b.w[i] + >= math.cos(x0) * b.x[i] + + (math.sin(x0) - x0 * math.cos(x0) - safety_tol) * b.lam[i] + ) + b.underestimators.add( + b.w[i] + >= math.cos(x1) * b.x[i] + + (math.sin(x1) - x1 * math.cos(x1) - safety_tol) * b.lam[i] + ) + + return x_pts + + +def pw_arctan_relaxation( + b, x, w, x_pts, relaxation_side=RelaxationSide.BOTH, safety_tol=1e-10 +): + """ + This function creates piecewise relaxations to relax "w=sin(x)" for -pi/2 <= x <= pi/2. + + Parameters + ---------- + b: pyo.Block + x: pyomo.core.base.var.SimpleVar or pyomo.core.base.var._GeneralVarData + The "x" variable in sin(x). The lower bound on x must greater than or equal to + -pi/2 and the upper bound on x must be less than or equal to pi/2. + w: pyomo.core.base.var.SimpleVar or pyomo.core.base.var._GeneralVarData + The auxiliary variable replacing sin(x) + x_pts: Sequence[float] + A list of floating point numbers to define the points over which the piecewise + representation will be generated. This list must be ordered, and it is expected + that the first point (x_pts[0]) is equal to x.lb and the last point (x_pts[-1]) + is equal to x.ub + relaxation_side: minlp.RelaxationSide + Provide the desired side for the relaxation (OVER, UNDER, or BOTH) + safety_tol: float + amount to lift the overestimator or drop the underestimator. This is used to ensure none of the feasible + region is cut off by error in computing the over and under estimators. + """ + check_var_pts(x, x_pts) + expr = pyo.atan(x) + _eval = _FxExpr(expr, x) + + xlb = x_pts[0] + xub = x_pts[-1] + + if x.is_fixed() or xlb == xub: + b.x_fixed_con = pyo.Constraint(expr=w == pyo.value(expr)) + return + + if xlb == -math.inf or xub == math.inf: + return + + (OE_tangent_x, OE_tangent_slope, OE_tangent_intercept) = ( + _compute_arctan_overestimator_tangent_point(xlb) + ) + ( + under_estimator_tangent_x, + under_estimator_tangent_slope, + under_estimator_tangent_intercept, + ) = _compute_arctan_underestimator_tangent_point(xub) + non_piecewise_overestimators_pts = [] + non_piecewise_underestimator_pts = [] + + if relaxation_side == RelaxationSide.OVER: + if OE_tangent_x < xub: + new_x_pts = [i for i in x_pts if i < OE_tangent_x] + new_x_pts.append(xub) + non_piecewise_overestimators_pts = [OE_tangent_x] + non_piecewise_overestimators_pts.extend( + i for i in x_pts if i > OE_tangent_x + ) + x_pts = new_x_pts + elif relaxation_side == RelaxationSide.UNDER: + if under_estimator_tangent_x > xlb: + new_x_pts = [xlb] + new_x_pts.extend(i for i in x_pts if i > under_estimator_tangent_x) + non_piecewise_underestimator_pts = [ + i for i in x_pts if i < under_estimator_tangent_x + ] + non_piecewise_underestimator_pts.append(under_estimator_tangent_x) + x_pts = new_x_pts + + b.non_piecewise_overestimators = pyo.ConstraintList() + b.non_piecewise_underestimators = pyo.ConstraintList() + for pt in non_piecewise_overestimators_pts: + b.non_piecewise_overestimators.add( + w <= math.atan(pt) + safety_tol + (x - pt) * _eval.deriv(pt) + ) + for pt in non_piecewise_underestimator_pts: + b.non_piecewise_underestimators.add( + w >= math.atan(pt) - safety_tol + (x - pt) * _eval.deriv(pt) + ) + + intervals = [] + for i in range(len(x_pts) - 1): + intervals.append((x_pts[i], x_pts[i + 1])) + + b.interval_set = pyo.Set(initialize=range(len(intervals))) + b.x = pyo.Var(b.interval_set) + b.w = pyo.Var(b.interval_set) + if len(intervals) == 1: + b.lam = pyo.Param(b.interval_set, mutable=True) + b.lam[0].value = 1.0 + else: + b.lam = pyo.Var(b.interval_set, within=pyo.Binary) + b.x_lb = pyo.ConstraintList() + b.x_ub = pyo.ConstraintList() + b.x_sum = pyo.Constraint(expr=x == sum(b.x[i] for i in b.interval_set)) + b.w_sum = pyo.Constraint(expr=w == sum(b.w[i] for i in b.interval_set)) + b.lam_sum = pyo.Constraint(expr=sum(b.lam[i] for i in b.interval_set) == 1) + b.overestimators = pyo.ConstraintList() + b.underestimators = pyo.ConstraintList() + + for i, tup in enumerate(intervals): + x0 = tup[0] + x1 = tup[1] + + b.x_lb.add(x0 * b.lam[i] <= b.x[i]) + b.x_ub.add(b.x[i] <= x1 * b.lam[i]) + + # Overestimators + if relaxation_side in {RelaxationSide.OVER, RelaxationSide.BOTH}: + if x0 < 0 and x1 <= 0: + slope = (math.atan(x1) - math.atan(x0)) / (x1 - x0) + intercept = math.atan(x0) - slope * x0 + b.overestimators.add( + b.w[i] <= slope * b.x[i] + (intercept + safety_tol) * b.lam[i] + ) + elif (x0 < 0) and (x1 > 0): + (tangent_x, tangent_slope, tangent_intercept) = ( + _compute_arctan_overestimator_tangent_point(x0) + ) + if tangent_x <= x1: + b.overestimators.add( + b.w[i] + <= tangent_slope * b.x[i] + + (tangent_intercept + safety_tol) * b.lam[i] + ) + b.overestimators.add( + b.w[i] + <= _eval.deriv(x1) * b.x[i] + + (math.atan(x1) - x1 * _eval.deriv(x1) + safety_tol) * b.lam[i] + ) + else: + slope = (math.atan(x1) - math.atan(x0)) / (x1 - x0) + intercept = math.atan(x0) - slope * x0 + b.overestimators.add( + b.w[i] <= slope * b.x[i] + (intercept + safety_tol) * b.lam[i] + ) + else: + b.overestimators.add( + b.w[i] + <= _eval.deriv(x0) * b.x[i] + + (math.atan(x0) - x0 * _eval.deriv(x0) + safety_tol) * b.lam[i] + ) + b.overestimators.add( + b.w[i] + <= _eval.deriv(x1) * b.x[i] + + (math.atan(x1) - x1 * _eval.deriv(x1) + safety_tol) * b.lam[i] + ) + + # Underestimators + if relaxation_side in {RelaxationSide.UNDER, RelaxationSide.BOTH}: + if x0 >= 0 and x1 > 0: + slope = (math.atan(x1) - math.atan(x0)) / (x1 - x0) + intercept = math.atan(x0) - slope * x0 + b.underestimators.add( + b.w[i] >= slope * b.x[i] + (intercept - safety_tol) * b.lam[i] + ) + elif (x1 > 0) and (x0 < 0): + (tangent_x, tangent_slope, tangent_intercept) = ( + _compute_arctan_underestimator_tangent_point(x1) + ) + if tangent_x >= x0: + b.underestimators.add( + b.w[i] + >= tangent_slope * b.x[i] + + (tangent_intercept - safety_tol) * b.lam[i] + ) + b.underestimators.add( + b.w[i] + >= _eval.deriv(x0) * b.x[i] + + (math.atan(x0) - x0 * _eval.deriv(x0) - safety_tol) * b.lam[i] + ) + else: + slope = (math.atan(x1) - math.atan(x0)) / (x1 - x0) + intercept = math.atan(x0) - slope * x0 + b.underestimators.add( + b.w[i] >= slope * b.x[i] + (intercept - safety_tol) * b.lam[i] + ) + else: + b.underestimators.add( + b.w[i] + >= _eval.deriv(x0) * b.x[i] + + (math.atan(x0) - x0 * _eval.deriv(x0) - safety_tol) * b.lam[i] + ) + b.underestimators.add( + b.w[i] + >= _eval.deriv(x1) * b.x[i] + + (math.atan(x1) - x1 * _eval.deriv(x1) - safety_tol) * b.lam[i] + ) + + return x_pts + + +@declare_custom_block(name='PWUnivariateRelaxation') +class PWUnivariateRelaxationData(BasePWRelaxationData): + """ + A helper class for building and modifying piecewise relaxations of aux_var = f(x) where f(x) is either convex + or concave. + """ + + def __init__(self, component): + super().__init__(component) + self._x = None + self._aux_var = None + self._pw_repn = 'INC' + self._function_shape = FunctionShape.UNKNOWN + self._f_x_expr = None + self._secant: Optional[Union[ScalarConstraint, IndexedConstraint]] = None + self._secant_expr: Optional[LinearExpression] = None + self._secant_slope: Optional[Union[ScalarParam, IndexedParam]] = None + self._secant_intercept: Optional[Union[ScalarParam, IndexedParam]] = None + self._pw_secant = None + + def get_rhs_vars(self): + return (self._x,) + + def get_rhs_expr(self): + return self._f_x_expr + + def vars_with_bounds_in_relaxation(self): + res = list() + if self.relaxation_side == RelaxationSide.BOTH: + res.append(self._x) + elif self.relaxation_side == RelaxationSide.UNDER and not self.is_rhs_convex(): + res.append(self._x) + elif self.relaxation_side == RelaxationSide.OVER and not self.is_rhs_concave(): + res.append(self._x) + return res + + def set_input( + self, + x, + aux_var, + shape, + f_x_expr, + pw_repn='INC', + relaxation_side=RelaxationSide.BOTH, + use_linear_relaxation=True, + large_coef=1e5, + small_coef=1e-10, + safety_tol=1e-10, + ): + """ + Parameters + ---------- + x: pyomo.core.base.var._GeneralVarData + The "x" variable in aux_var = f(x). + aux_var: pyomo.core.base.var._GeneralVarData + The auxiliary variable replacing f(x) + shape: FunctionShape + Options are FunctionShape.CONVEX and FunctionShape.CONCAVE + f_x_expr: pyomo expression + The pyomo expression representing f(x) + pw_repn: str + This must be one of the valid strings for the piecewise representation to use (directly from the Piecewise + component). Use help(Piecewise) to learn more. + relaxation_side: RelaxationSide + Provide the desired side for the relaxation (OVER, UNDER, or BOTH) + use_linear_relaxation: bool + Specifies whether a linear or nonlinear relaxation should be used + """ + super().set_input( + relaxation_side=relaxation_side, + use_linear_relaxation=use_linear_relaxation, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + ) + self._pw_repn = pw_repn + self._function_shape = shape + self._f_x_expr = f_x_expr + + object.__setattr__(self, '_x', x) + object.__setattr__(self, '_aux_var', aux_var) + bnds_list = _get_bnds_list(self._x) + self._partitions[self._x] = bnds_list + + def build( + self, + x, + aux_var, + shape, + f_x_expr, + pw_repn='INC', + relaxation_side=RelaxationSide.BOTH, + use_linear_relaxation=True, + large_coef=1e5, + small_coef=1e-10, + safety_tol=1e-10, + ): + """ + Parameters + ---------- + x: pyomo.core.base.var._GeneralVarData + The "x" variable in aux_var = f(x). + aux_var: pyomo.core.base.var._GeneralVarData + The auxiliary variable replacing f(x) + shape: FunctionShape + Options are FunctionShape.CONVEX and FunctionShape.CONCAVE + f_x_expr: pyomo expression + The pyomo expression representing f(x) + pw_repn: str + This must be one of the valid strings for the piecewise representation to use (directly from the Piecewise + component). Use help(Piecewise) to learn more. + relaxation_side: RelaxationSide + Provide the desired side for the relaxation (OVER, UNDER, or BOTH) + use_linear_relaxation: bool + Specifies whether a linear or nonlinear relaxation should be used + """ + self.set_input( + x=x, + aux_var=aux_var, + shape=shape, + f_x_expr=f_x_expr, + pw_repn=pw_repn, + relaxation_side=relaxation_side, + use_linear_relaxation=use_linear_relaxation, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + ) + self.rebuild() + + def _remove_relaxation(self): + del self._secant, self._secant_slope, self._secant_intercept, self._pw_secant + self._secant = None + self._secant_expr = None + self._secant_slope = None + self._secant_intercept = None + self._pw_secant = None + + def remove_relaxation(self): + super().remove_relaxation() + self._remove_relaxation() + + def _needs_secant(self): + if self.relaxation_side == RelaxationSide.BOTH and ( + self.is_rhs_convex() or self.is_rhs_concave() + ): + return True + elif self.relaxation_side == RelaxationSide.UNDER and self.is_rhs_concave(): + return True + elif self.relaxation_side == RelaxationSide.OVER and self.is_rhs_convex(): + return True + else: + return False + + def rebuild(self, build_nonlinear_constraint=False, ensure_oa_at_vertices=True): + super().rebuild( + build_nonlinear_constraint=build_nonlinear_constraint, + ensure_oa_at_vertices=ensure_oa_at_vertices, + ) + if not build_nonlinear_constraint: + if self._check_valid_domain_for_relaxation(): + if self._needs_secant(): + if len(self._partitions[self._x]) == 2: + if self._secant is None: + self._remove_relaxation() + self._build_secant() + self._update_secant() + else: + self._remove_relaxation() + self._build_pw_secant() + else: + self._remove_relaxation() + else: + self._remove_relaxation() + + def _build_secant(self): + del self._secant_slope + del self._secant_intercept + del self._secant + del self._secant_expr + self._secant_slope = ScalarParam(mutable=True) + self._secant_intercept = ScalarParam(mutable=True) + e = LinearExpression( + constant=self._secant_intercept, + linear_coefs=[self._secant_slope], + linear_vars=[self._x], + ) + self._secant_expr = e + if self.is_rhs_concave(): + self._secant = ScalarConstraint(expr=self._aux_var >= e) + elif self.is_rhs_convex(): + self._secant = ScalarConstraint(expr=self._aux_var <= e) + else: + raise RuntimeError( + 'Function should be either convex or concave in order to build the secant' + ) + + def _update_secant(self): + _eval = _FxExpr(self._f_x_expr, self._x) + assert len(self._partitions[self._x]) == 2 + + try: + x1 = self._partitions[self._x][0] + x2 = self._partitions[self._x][1] + if x1 == x2: + slope = 0 + intercept = _eval(x1) + else: + y1 = _eval(x1) + y2 = _eval(x2) + slope = (y2 - y1) / (x2 - x1) + intercept = y2 - slope * x2 + err_message = None + except (ZeroDivisionError, OverflowError, ValueError) as e: + slope = None + intercept = None + err_message = str(e) + if err_message is not None: + logger.debug( + f'Encountered exception when adding secant for "{self._get_pprint_string()}"; Error message: {err_message}' + ) + self._remove_relaxation() + else: + self._secant_slope._value = slope + self._secant_intercept._value = intercept + if self.is_rhs_concave(): + rel_side = RelaxationSide.UNDER + else: + rel_side = RelaxationSide.OVER + success, bad_var, bad_coef, err_msg = _check_cut( + self._secant_expr, + too_small=self.small_coef, + too_large=self.large_coef, + relaxation_side=rel_side, + safety_tol=self.safety_tol, + ) + if not success: + self._log_bad_cut(bad_var, bad_coef, err_msg) + self._secant.deactivate() + else: + self._secant.activate() + + def _build_pw_secant(self): + del self._pw_secant + self._pw_secant = pe.Block(concrete=True) + if self.is_rhs_convex(): + _pw_univariate_relaxation( + b=self._pw_secant, + x=self._x, + w=self._aux_var, + x_pts=self._partitions[self._x], + f_x_expr=self._f_x_expr, + pw_repn=self._pw_repn, + shape=FunctionShape.CONVEX, + relaxation_side=RelaxationSide.OVER, + large_eval_tol=self.large_coef, + safety_tol=self.safety_tol, + ) + else: + _pw_univariate_relaxation( + b=self._pw_secant, + x=self._x, + w=self._aux_var, + x_pts=self._partitions[self._x], + f_x_expr=self._f_x_expr, + pw_repn=self._pw_repn, + shape=FunctionShape.CONCAVE, + relaxation_side=RelaxationSide.UNDER, + large_eval_tol=self.large_coef, + safety_tol=self.safety_tol, + ) + + def add_partition_point(self, value=None): + """ + This method adds one point to the partitioning of x. If value is not + specified, a single point will be added to the partitioning of x at the current value of x. If value is + specified, then value is added to the partitioning of x. + + Parameters + ---------- + value: float + The point to be added to the partitioning of x. + """ + self._add_partition_point(self._x, value) + + def is_rhs_convex(self): + """ + Returns True if linear underestimators do not need binaries. Otherwise, returns False. + + Returns + ------- + bool + """ + return self._function_shape == FunctionShape.CONVEX + + def is_rhs_concave(self): + """ + Returns True if linear overestimators do not need binaries. Otherwise, returns False. + + Returns + ------- + bool + """ + return self._function_shape == FunctionShape.CONCAVE + + @property + def use_linear_relaxation(self): + return self._use_linear_relaxation + + @use_linear_relaxation.setter + def use_linear_relaxation(self, val): + self._use_linear_relaxation = val + + +@declare_custom_block(name='CustomUnivariateBaseRelaxation') +class CustomUnivariateBaseRelaxationData(PWUnivariateRelaxationData): + """ + A helper class for building and modifying piecewise relaxations of aux_var = x**2. + """ + + def _rhs_func(self, x): + raise NotImplementedError('This should be implemented by a derived class') + + def set_input( + self, + x, + aux_var, + pw_repn='INC', + relaxation_side=RelaxationSide.BOTH, + use_linear_relaxation=True, + large_coef=1e5, + small_coef=1e-10, + safety_tol=1e-10, + ): + """ + Parameters + ---------- + x: pyomo.core.base.var._GeneralVarData + The "x" variable in aux_var = f(x). + aux_var: pyomo.core.base.var._GeneralVarData + The auxiliary variable replacing f(x) + pw_repn: str + This must be one of the valid strings for the piecewise representation to use (directly from the Piecewise + component). Use help(Piecewise) to learn more. + relaxation_side: RelaxationSide + Provide the desired side for the relaxation (OVER, UNDER, or BOTH) + use_linear_relaxation: bool + Specifies whether a linear or nonlinear relaxation should be used + """ + super().set_input( + x=x, + aux_var=aux_var, + shape=FunctionShape.UNKNOWN, + f_x_expr=self._rhs_func(x), + pw_repn=pw_repn, + relaxation_side=relaxation_side, + use_linear_relaxation=use_linear_relaxation, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + ) + + def build( + self, + x, + aux_var, + pw_repn='INC', + relaxation_side=RelaxationSide.BOTH, + use_linear_relaxation=True, + large_coef=1e5, + small_coef=1e-10, + safety_tol=1e-10, + ): + """ + Parameters + ---------- + x: pyomo.core.base.var._GeneralVarData + The "x" variable in aux_var = f(x). + aux_var: pyomo.core.base.var._GeneralVarData + The auxiliary variable replacing f(x) + pw_repn: str + This must be one of the valid strings for the piecewise representation to use (directly from the Piecewise + component). Use help(Piecewise) to learn more. + relaxation_side: RelaxationSide + Provide the desired side for the relaxation (OVER, UNDER, or BOTH) + use_linear_relaxation: bool + Specifies whether a linear or nonlinear relaxation should be used + """ + self.set_input( + x=x, + aux_var=aux_var, + pw_repn=pw_repn, + relaxation_side=relaxation_side, + use_linear_relaxation=use_linear_relaxation, + large_coef=large_coef, + small_coef=small_coef, + safety_tol=safety_tol, + ) + self.rebuild() + + +@declare_custom_block(name='PWXSquaredRelaxation') +class PWXSquaredRelaxationData(CustomUnivariateBaseRelaxationData): + """ + A helper class for building and modifying piecewise relaxations of aux_var = x**2. + """ + + def _rhs_func(self, x): + return x**2 + + def is_rhs_convex(self): + return True + + +@declare_custom_block(name='PWCosRelaxation') +class PWCosRelaxationData(CustomUnivariateBaseRelaxationData): + """ + A helper class for building and modifying piecewise relaxations of w = cos(x) for -pi/2 <= x <= pi/2. + """ + + def __init__(self, component): + super().__init__(component) + self._last_concave = None + + def rebuild(self, build_nonlinear_constraint=False, ensure_oa_at_vertices=True): + current_concave = self.is_rhs_concave() + if current_concave != self._last_concave: + self._needs_rebuilt = True + self._last_concave = current_concave + super().rebuild( + build_nonlinear_constraint=build_nonlinear_constraint, + ensure_oa_at_vertices=ensure_oa_at_vertices, + ) + + def _rhs_func(self, x): + return pe.cos(x) + + def is_rhs_concave(self): + lb, ub = tuple(_get_bnds_list(self._x)) + if lb >= -math.pi / 2 and ub <= math.pi / 2: + return True + else: + return False + + +@declare_custom_block(name='SinArctanBaseRelaxation') +class SinArctanBaseRelaxationData(CustomUnivariateBaseRelaxationData): + """ + A helper class for building and modifying piecewise relaxations of w = sin(x) for -pi/2 <= x <= pi/2. + """ + + def _rhs_func(self, x): + raise NotImplementedError('This should be implemented by a derived class') + + def __init__(self, component): + super().__init__(component) + self._secant_index = None + self._secant_exprs = None + self._last_convex = None + self._last_concave = None + + def _remove_relaxation(self): + super()._remove_relaxation() + del self._secant_index + del self._secant_exprs + self._secant_index = None + self._secant_exprs = None + + def _pw_func(self): + raise NotImplementedError('This should be implemented by a derived class') + + def _underestimator_func(self): + raise NotImplementedError('This should be implemented by a derived class') + + def _overestimator_func(self): + raise NotImplementedError('This should be implemented by a derived class') + + def rebuild(self, build_nonlinear_constraint=False, ensure_oa_at_vertices=True): + current_convex = self.is_rhs_convex() + current_concave = self.is_rhs_concave() + if current_convex != self._last_convex or current_concave != self._last_concave: + self._needs_rebuilt = True + self._last_convex = current_convex + self._last_concave = current_concave + super().rebuild( + build_nonlinear_constraint=build_nonlinear_constraint, + ensure_oa_at_vertices=ensure_oa_at_vertices, + ) + if not build_nonlinear_constraint: + if self._check_valid_domain_for_relaxation(): + if (not self.is_rhs_convex()) and (not self.is_rhs_concave()): + if len(self._partitions[self._x]) == 2: + if self._secant is None: + self._remove_relaxation() + self._build_relaxation() + self._update_relaxation() + else: + self._remove_relaxation() + del self._pw_secant + self._pw_secant = pe.Block(concrete=True) + self._pw_func()( + b=self._pw_secant, + x=self._x, + w=self._aux_var, + x_pts=self._partitions[self._x], + relaxation_side=self.relaxation_side, + safety_tol=self.safety_tol, + ) + else: + self._remove_relaxation() + + def _build_relaxation(self): + del self._secant_index, self._secant_slope, self._secant_intercept, self._secant + self._secant_index = pe.Set(initialize=[0, 1, 2, 3]) + self._secant_exprs = dict() + self._secant_slope = IndexedParam(self._secant_index, mutable=True) + self._secant_intercept = IndexedParam(self._secant_index, mutable=True) + self._secant = IndexedConstraint(self._secant_index) + if self.relaxation_side in {RelaxationSide.BOTH, RelaxationSide.UNDER}: + for ndx in [0, 1]: + e = LinearExpression( + constant=self._secant_intercept[ndx], + linear_coefs=[self._secant_slope[ndx]], + linear_vars=[self._x], + ) + self._secant_exprs[ndx] = e + self._secant[ndx] = self._aux_var >= e + if self.relaxation_side in {RelaxationSide.BOTH, RelaxationSide.OVER}: + for ndx in [2, 3]: + e = LinearExpression( + constant=self._secant_intercept[ndx], + linear_coefs=[self._secant_slope[ndx]], + linear_vars=[self._x], + ) + self._secant_exprs[ndx] = e + self._secant[ndx] = self._aux_var <= e + + def _check_expr(self, ndx): + if ndx in {0, 1}: + rel_side = RelaxationSide.UNDER + else: + rel_side = RelaxationSide.OVER + success, bad_var, bad_coef, err_msg = _check_cut( + self._secant_exprs[ndx], + too_small=self.small_coef, + too_large=self.large_coef, + relaxation_side=rel_side, + safety_tol=self.safety_tol, + ) + if not success: + self._log_bad_cut(bad_var, bad_coef, err_msg) + self._secant[ndx].deactivate() + else: + self._secant[ndx].activate() + + def _update_relaxation(self): + xlb, xub = _get_bnds_tuple(self._x) + _eval = _FxExpr(self.get_rhs_expr(), self._x) + if self.relaxation_side in {RelaxationSide.BOTH, RelaxationSide.UNDER}: + tangent_x, tangent_slope, tangent_int = self._underestimator_func()(xub) + if tangent_x >= xlb: + self._secant_slope[0]._value = tangent_slope + self._secant_intercept[0]._value = tangent_int + self._secant_slope[1]._value = _eval.deriv(xlb) + self._secant_intercept[1]._value = _eval(xlb) - xlb * _eval.deriv(xlb) + self._check_expr(0) + self._check_expr(1) + else: + y1 = _eval(xlb) + y2 = _eval(xub) + slope = (y2 - y1) / (xub - xlb) + intercept = y2 - slope * xub + self._secant_slope[0]._value = slope + self._secant_intercept[0]._value = intercept + self._check_expr(0) + self._secant[1].deactivate() + + if self.relaxation_side in {RelaxationSide.BOTH, RelaxationSide.OVER}: + tangent_x, tangent_slope, tangent_int = self._overestimator_func()(xlb) + if tangent_x <= xub: + self._secant_slope[2]._value = tangent_slope + self._secant_intercept[2]._value = tangent_int + self._secant_slope[3]._value = _eval.deriv(xub) + self._secant_intercept[3]._value = _eval(xub) - xub * _eval.deriv(xub) + self._check_expr(2) + self._check_expr(3) + else: + y1 = _eval(xlb) + y2 = _eval(xub) + slope = (y2 - y1) / (xub - xlb) + intercept = y2 - slope * xub + self._secant_slope[2]._value = slope + self._secant_intercept[2]._value = intercept + self._check_expr(2) + self._secant[3].deactivate() + + +@declare_custom_block(name='PWSinRelaxation') +class PWSinRelaxationData(SinArctanBaseRelaxationData): + """ + A helper class for building and modifying piecewise relaxations of w = sin(x) for -pi/2 <= x <= pi/2. + """ + + def _rhs_func(self, x): + return pe.sin(x) + + def _check_valid_domain_for_relaxation(self) -> bool: + lb, ub = _get_bnds_tuple(self._x) + if lb >= -math.pi / 2 and ub <= math.pi / 2: + return True + return False + + def _pw_func(self): + return pw_sin_relaxation + + def _underestimator_func(self): + return _compute_sine_underestimator_tangent_point + + def _overestimator_func(self): + return _compute_sine_overestimator_tangent_point + + def is_rhs_convex(self): + """ + Returns True if linear underestimators do not need binaries. Otherwise, returns False. + + Returns + ------- + bool + """ + lb, ub = tuple(_get_bnds_list(self._x)) + if lb >= -math.pi / 2 and ub <= 0: + return True + return False + + def is_rhs_concave(self): + """ + Returns True if linear overestimators do not need binaries. Otherwise, returns False. + + Returns + ------- + bool + """ + lb, ub = tuple(_get_bnds_list(self._x)) + if lb >= 0 and ub <= math.pi / 2: + return True + return False + + +@declare_custom_block(name='PWArctanRelaxation') +class PWArctanRelaxationData(SinArctanBaseRelaxationData): + """ + A helper class for building and modifying piecewise relaxations of w = arctan(x). + """ + + def _rhs_func(self, x): + return pe.atan(x) + + def _pw_func(self): + return pw_arctan_relaxation + + def _underestimator_func(self): + return _compute_arctan_underestimator_tangent_point + + def _overestimator_func(self): + return _compute_arctan_overestimator_tangent_point + + def is_rhs_convex(self): + """ + Returns True if linear underestimators do not need binaries. Otherwise, returns False. + + Returns + ------- + bool + """ + lb, ub = tuple(_get_bnds_list(self._x)) + if ub <= 0: + return True + return False + + def is_rhs_concave(self): + """ + Returns True if linear overestimators do not need binaries. Otherwise, returns False. + + Returns + ------- + bool + """ + lb, ub = tuple(_get_bnds_list(self._x)) + if lb >= 0: + return True + return False diff --git a/pyomo/contrib/coramin/third_party/__init__.py b/pyomo/contrib/coramin/third_party/__init__.py new file mode 100644 index 00000000000..19f45f06b56 --- /dev/null +++ b/pyomo/contrib/coramin/third_party/__init__.py @@ -0,0 +1,5 @@ +from .minlplib_tools import ( + get_minlplib_instancedata, + filter_minlplib_instances, + get_minlplib, +) diff --git a/pyomo/contrib/coramin/third_party/minlplib_tools.py b/pyomo/contrib/coramin/third_party/minlplib_tools.py new file mode 100644 index 00000000000..ff37cf12a51 --- /dev/null +++ b/pyomo/contrib/coramin/third_party/minlplib_tools.py @@ -0,0 +1,724 @@ +import os +from pyomo.common import download +import math +import csv +from collections.abc import Iterable +import logging +from xml.etree import ElementTree +import pyomo.environ as pe +from pyomo.core.base.block import ScalarBlock +from pyomo.core.base.var import IndexedVar +from pyomo.core.base.constraint import IndexedConstraint +from pyomo.core.expr.numeric_expr import LinearExpression + + +logger = logging.getLogger(__name__) + + +def get_minlplib_instancedata(target_filename=None): + """ + Download instancedata.csv from MINLPLib which can be used to get statistics on the problems from minlplib. + + Parameters + ---------- + target_filename: str + The full path, including the filename for where to place the downloaded + file. The default will be a directory called minlplib in the current + working directory and a filename of instancedata.csv. + """ + if target_filename is None: + target_filename = os.path.join(os.getcwd(), 'minlplib', 'instancedata.csv') + download_dir = os.path.dirname(target_filename) + + if os.path.exists(target_filename): + raise ValueError( + 'A file named {filename} already exists.'.format(filename=target_filename) + ) + + if not os.path.exists(download_dir): + os.makedirs(download_dir) + + downloader = download.FileDownloader() + downloader.set_destination_filename(target_filename) + downloader.get_text_file('http://www.minlplib.org/instancedata.csv') + + +def _process_acceptable_arg(name, arg, default): + if arg is None: + return default + if isinstance(arg, str): + if arg not in default: + raise ValueError("Unrecognized argument for %s: %s" % (name, arg)) + return set([arg]) + if isinstance(arg, Iterable): + ans = set(str(_) for _ in arg) + if not ans.issubset(default): + unknown = default - ans + raise ValueError("Unrecognized argument for %s: %s" % (name, unknown)) + return ans + if type(arg) == bool: + if str(arg) in default: + return set([str(arg)]) + raise ValueError('unrecognized type for %s: %s' % (name, type(arg))) + + +def _check_int_arg(arg, _min, _max, arg_name, case_name): + if arg < _min or arg > _max: + logger.debug( + 'excluding {case_name} due to {arg_name}'.format( + case_name=case_name, arg_name=arg_name + ) + ) + return True + return False + + +def _check_acceptable(arg, acceptable_set, arg_name, case_name): + if arg not in acceptable_set: + logger.debug( + 'excluding {case_name} due to {arg_name}'.format( + case_name=case_name, arg_name=arg_name + ) + ) + return True + return False + + +def filter_minlplib_instances( + instancedata_filename=None, + min_nvars=0, + max_nvars=math.inf, + min_nbinvars=0, + max_nbinvars=math.inf, + min_nintvars=0, + max_nintvars=math.inf, + min_nnlvars=0, + max_nnlvars=math.inf, + min_nnlbinvars=0, + max_nnlbinvars=math.inf, + min_nnlintvars=0, + max_nnlintvars=math.inf, + min_nobjnz=0, + max_nobjnz=math.inf, + min_nobjnlnz=0, + max_nobjnlnz=math.inf, + min_ncons=0, + max_ncons=math.inf, + min_nlincons=0, + max_nlincons=math.inf, + min_nquadcons=0, + max_nquadcons=math.inf, + min_npolynomcons=0, + max_npolynomcons=math.inf, + min_nsignomcons=0, + max_nsignomcons=math.inf, + min_ngennlcons=0, + max_ngennlcons=math.inf, + min_njacobiannz=0, + max_njacobiannz=math.inf, + min_njacobiannlnz=0, + max_njacobiannlnz=math.inf, + min_nlaghessiannz=0, + max_nlaghessiannz=math.inf, + min_nlaghessiandiagnz=0, + max_nlaghessiandiagnz=math.inf, + min_nsemi=0, + max_nsemi=math.inf, + min_nnlsemi=0, + max_nnlsemi=math.inf, + min_nsos1=0, + max_nsos1=math.inf, + min_nsos2=0, + max_nsos2=math.inf, + acceptable_formats=None, + acceptable_probtype=None, + acceptable_objtype=None, + acceptable_objcurvature=None, + acceptable_conscurvature=None, + acceptable_convex=None, +): + """ + This function filters problems from MINLPLib based on + instancedata.csv from MINLPLib and the conditions specified + through the function arguments. The function argument names + correspond to column headings from instancedata.csv. The + arguments starting with min or max require int or float inputs. + The arguments starting with acceptable require either a + string or an iterable of strings. See the MINLPLib documentation + for acceptable values. + """ + if instancedata_filename is None: + instancedata_filename = os.path.join( + os.getcwd(), 'minlplib', 'instancedata.csv' + ) + + if not os.path.exists(instancedata_filename): + raise RuntimeError( + '{filename} does not exist. Please use get_minlplib_instancedata() first or specify the location of the MINLPLib instancedata.csv with the instancedata_filename argument.'.format( + filename=instancedata_filename + ) + ) + + acceptable_formats = _process_acceptable_arg( + 'acceptable_formats', + acceptable_formats, + set(['ams', 'gms', 'lp', 'mod', 'nl', 'osil', 'pip']), + ) + + default_acceptable_probtype = set() + for pre in ['B', 'I', 'MI', 'MB', 'S', '']: + for post in ['NLP', 'QCQP', 'QP', 'QCP', 'P']: + default_acceptable_probtype.add(pre + post) + acceptable_probtype = _process_acceptable_arg( + 'acceptable_probtype', acceptable_probtype, default_acceptable_probtype + ) + + acceptable_objtype = _process_acceptable_arg( + 'acceptable_objtype', + acceptable_objtype, + set( + ['constant', 'linear', 'quadratic', 'polynomial', 'signomial', 'nonlinear'] + ), + ) + + acceptable_objcurvature = _process_acceptable_arg( + 'acceptable_objcurvature', + acceptable_objcurvature, + set( + [ + 'linear', + 'convex', + 'concave', + 'indefinite', + 'nonconvex', + 'nonconcave', + 'unknown', + ] + ), + ) + + acceptable_conscurvature = _process_acceptable_arg( + 'acceptable_conscurvature', + acceptable_conscurvature, + set( + [ + 'linear', + 'convex', + 'concave', + 'indefinite', + 'nonconvex', + 'nonconcave', + 'unknown', + ] + ), + ) + + acceptable_convex = _process_acceptable_arg( + 'acceptable_convex', acceptable_convex, set(['True', 'False', '']) + ) + + int_arg_name_list = [ + 'nvars', + 'nbinvars', + 'nintvars', + 'nnlvars', + 'nnlbinvars', + 'nnlintvars', + 'nobjnz', + 'nobjnlnz', + 'ncons', + 'nlincons', + 'nquadcons', + 'npolynomcons', + 'nsignomcons', + 'ngennlcons', + 'njacobiannz', + 'njacobiannlnz', + 'nlaghessiannz', + 'nlaghessiandiagnz', + 'nsemi', + 'nnlsemi', + 'nsos1', + 'nsos2', + ] + min_list = [ + min_nvars, + min_nbinvars, + min_nintvars, + min_nnlvars, + min_nnlbinvars, + min_nnlintvars, + min_nobjnz, + min_nobjnlnz, + min_ncons, + min_nlincons, + min_nquadcons, + min_npolynomcons, + min_nsignomcons, + min_ngennlcons, + min_njacobiannz, + min_njacobiannlnz, + min_nlaghessiannz, + min_nlaghessiandiagnz, + min_nsemi, + min_nnlsemi, + min_nsos1, + min_nsos2, + ] + max_list = [ + max_nvars, + max_nbinvars, + max_nintvars, + max_nnlvars, + max_nnlbinvars, + max_nnlintvars, + max_nobjnz, + max_nobjnlnz, + max_ncons, + max_nlincons, + max_nquadcons, + max_npolynomcons, + max_nsignomcons, + max_ngennlcons, + max_njacobiannz, + max_njacobiannlnz, + max_nlaghessiannz, + max_nlaghessiandiagnz, + max_nsemi, + max_nnlsemi, + max_nsos1, + max_nsos2, + ] + + acceptable_arg_name_list = [ + 'probtype', + 'objtype', + 'objcurvature', + 'conscurvature', + 'convex', + ] + acceptable_set_list = [ + acceptable_probtype, + acceptable_objtype, + acceptable_objcurvature, + acceptable_conscurvature, + acceptable_convex, + ] + + with open(instancedata_filename, 'r') as csv_file: + reader = csv.reader(csv_file, delimiter=';') + headings = {column: ndx for ndx, column in enumerate(next(reader))} + rows = [row for row in reader] + + cases = list() + for ndx, row in enumerate(rows): + if len(row) == 0: + continue + + case_name = row[headings['name']] + + available_formats = row[headings['formats']] + available_formats = available_formats.replace('set([', '') + available_formats = available_formats.replace('])', '') + available_formats = available_formats.replace('{', '') + available_formats = available_formats.replace('}', '') + available_formats = available_formats.replace(' ', '') + available_formats = available_formats.replace("'", '') + available_formats = available_formats.split(',') + available_formats = set(available_formats) + + should_continue = False + + if len(acceptable_formats.intersection(available_formats)) == 0: + logger.debug( + 'excluding {case} due to available_formats'.format(case=case_name) + ) + should_continue = True + + for ndx, acceptable_arg_name in enumerate(acceptable_arg_name_list): + acceptable_set = acceptable_set_list[ndx] + arg = row[headings[acceptable_arg_name]] + if _check_acceptable( + arg=arg, + acceptable_set=acceptable_set, + arg_name=acceptable_arg_name, + case_name=case_name, + ): + should_continue = True + + for ndx, arg_name in enumerate(int_arg_name_list): + _min = min_list[ndx] + _max = max_list[ndx] + arg = int(row[headings[arg_name]]) + if _check_int_arg( + arg=arg, _min=_min, _max=_max, arg_name=arg_name, case_name=case_name + ): + should_continue = True + + if should_continue: + continue + + cases.append(case_name) + + return cases + + +def get_minlplib(download_dir=None, format='osil', problem_name=None): + """ + Download MINLPLib + + Parameters + ---------- + download_dir: str + The directory in which to place the downloaded files. The default will be a + current_working_directory/minlplib/file_format/. + format: str + The file format requested. Options are ams, gms, lp, mod, nl, osil, and pip + problem_name: None or str + If problem_name is None, then the entire zip file will be downloaded + and extracted (all problems with the specified format). If a single problem + needs to be downloaded, then the name of the problem can be specified. + This can be significantly faster than downloading all of the problems. + However, individual problems are not compressed, so downloading multiple + individual problems can quickly become expensive. + """ + if download_dir is None: + download_dir = os.path.join(os.getcwd(), 'minlplib', format) + + if problem_name is None: + if os.path.exists(download_dir): + raise ValueError( + 'The specified download_dir already exists: ' + download_dir + ) + + os.makedirs(download_dir) + downloader = download.FileDownloader() + zip_dirname = os.path.join(download_dir, 'minlplib_' + format) + downloader.set_destination_filename(zip_dirname) + downloader.get_zip_archive( + 'http://www.minlplib.org/minlplib_' + format + '.zip' + ) + for i in os.listdir( + os.path.join(download_dir, 'minlplib_' + format, 'minlplib', format) + ): + os.rename( + os.path.join(download_dir, 'minlplib_' + format, 'minlplib', format, i), + os.path.join(download_dir, i), + ) + os.rmdir(os.path.join(download_dir, 'minlplib_' + format, 'minlplib', format)) + os.rmdir(os.path.join(download_dir, 'minlplib_' + format, 'minlplib')) + os.rmdir(os.path.join(download_dir, 'minlplib_' + format)) + else: + if not os.path.exists(download_dir): + os.makedirs(download_dir) + target_filename = os.path.join(download_dir, problem_name + '.' + format) + if os.path.exists(target_filename): + raise ValueError(f'The target filename ({target_filename}) already exists') + downloader = download.FileDownloader() + downloader.set_destination_filename(target_filename) + downloader.get_binary_file( + 'http://www.minlplib.org/' + format + '/' + problem_name + '.' + format + ) + + +def _handle_negate_osil(node, var_map): + assert len(node) == 1 + return -_parse_nonlinear_expression_osil(node[0], var_map) + + +def _handle_divide_osil(node, var_map): + assert len(node) == 2 + arg1 = _parse_nonlinear_expression_osil(node[0], var_map) + arg2 = _parse_nonlinear_expression_osil(node[1], var_map) + return arg1 / arg2 + + +def _handle_sum_osil(node, var_map): + res = 0 + for i in node: + arg = _parse_nonlinear_expression_osil(i, var_map) + res += arg + return res + + +def _handle_product_osil(node, var_map): + res = 1 + for i in node: + arg = _parse_nonlinear_expression_osil(i, var_map) + res *= arg + return res + + +def _handle_variable_osil(node, var_map): + assert len(node) == 0 + ndx = int(node.attrib['idx']) + v = var_map[ndx] + if 'coef' in node.attrib: + coef = float(node.attrib['coef']) + else: + coef = 1 + return v * coef + + +def _handle_log_osil(node, var_map): + assert len(node) == 1 + return pe.log(_parse_nonlinear_expression_osil(node[0], var_map)) + + +def _handle_exp_osil(node, var_map): + assert len(node) == 1 + return pe.exp(_parse_nonlinear_expression_osil(node[0], var_map)) + + +def _handle_number_osil(node, var_map): + assert len(node) == 0 + return float(node.attrib['value']) + + +def _handle_square_osil(node, var_map): + assert len(node) == 1 + return _parse_nonlinear_expression_osil(node[0], var_map) ** 2 + + +def _handle_power_osil(node, var_map): + assert len(node) == 2 + arg1 = _parse_nonlinear_expression_osil(node[0], var_map) + arg2 = _parse_nonlinear_expression_osil(node[1], var_map) + return arg1**arg2 + + +_osil_operator_map = dict() +_osil_operator_map['{os.optimizationservices.org}negate'] = _handle_negate_osil +_osil_operator_map['{os.optimizationservices.org}divide'] = _handle_divide_osil +_osil_operator_map['{os.optimizationservices.org}sum'] = _handle_sum_osil +_osil_operator_map['{os.optimizationservices.org}product'] = _handle_product_osil +_osil_operator_map['{os.optimizationservices.org}variable'] = _handle_variable_osil +_osil_operator_map['{os.optimizationservices.org}ln'] = _handle_log_osil +_osil_operator_map['{os.optimizationservices.org}exp'] = _handle_exp_osil +_osil_operator_map['{os.optimizationservices.org}number'] = _handle_number_osil +_osil_operator_map['{os.optimizationservices.org}square'] = _handle_square_osil +_osil_operator_map['{os.optimizationservices.org}power'] = _handle_power_osil + + +def _parse_nonlinear_expression_osil(node, var_map): + return _osil_operator_map[node.tag](node, var_map) + + +def parse_osil_file(fname) -> ScalarBlock: + tree = ElementTree.parse(fname) + ns = '{os.optimizationservices.org}' + root = tree.getroot() + + instance_data = list(root.iter(ns + 'instanceData')) + assert len(instance_data) == 1 + instance_data = instance_data[0] + acceptable_nodes = set( + ns + i + for i in [ + 'variables', + 'objectives', + 'constraints', + 'linearConstraintCoefficients', + 'quadraticCoefficients', + 'nonlinearExpressions', + ] + ) + for i in instance_data: + if i.tag not in acceptable_nodes: + raise ValueError(f'Unexpected xml node: {i.tag}') + instance_data_nodes = set(i.tag for i in instance_data) + + m = ScalarBlock(concrete=True) + + variables_node = list(instance_data.iter(ns + 'variables'))[0] + vnames = list() + for v in variables_node.iter(ns + 'var'): + vnames.append(v.attrib['name']) + + m.var_names = pe.Set(initialize=vnames) + m.vars = IndexedVar(m.var_names) + + type_map = {'B': pe.Binary, 'I': pe.Integers} + + for v in variables_node.iter(ns + 'var'): + vdata = v.attrib + vname = vdata.pop('name') + if 'lb' in vdata: + vlb = vdata.pop('lb') + if vlb == '-INF': + vlb = None + else: + vlb = float(vlb) + else: + vlb = 0 + if 'ub' in vdata: + vub = float(vdata.pop('ub')) + else: + vub = None + if 'type' in vdata: + if vdata['type'] not in type_map: + raise ValueError(f"Unrecognized variable type: {vdata['type']}") + vtype = type_map[vdata.pop('type')] + else: + vtype = pe.Reals + m.vars[vname].setlb(vlb) + m.vars[vname].setub(vub) + m.vars[vname].domain = vtype + assert len(vdata) == 0 + + con_names = [] + con_lbs = [] + con_ubs = [] + constraints_node = list(instance_data.iter(ns + 'constraints'))[0] + for c in constraints_node.iter(ns + 'con'): + cdata = c.attrib + cname = cdata.pop('name') + if 'lb' in cdata: + clb = float(cdata.pop('lb')) + else: + clb = None + if 'ub' in cdata: + cub = float(cdata.pop('ub')) + else: + cub = None + con_names.append(cname) + con_lbs.append(clb) + con_ubs.append(cub) + + # osil format specifies the linear parts of the constraints in CSR format + if (ns + 'linearConstraintCoefficients') in instance_data_nodes: + linpart = list(instance_data.iter(ns + 'linearConstraintCoefficients')) + assert len(linpart) == 1 + linpart = linpart[0] + rowstart = list(linpart.iter(ns + 'start'))[0] + colind = list(linpart.iter(ns + 'colIdx'))[0] + vals = list(linpart.iter(ns + 'value'))[0] + + tmp = list() + for i in rowstart: + s = int(i.text) + n = int(i.attrib.pop('mult', 1)) - 1 + step = int(i.attrib.pop('incr', 0)) + tmp.append(s) + for _ in range(n): + s += step + tmp.append(s) + rowstart = tmp + assert len(rowstart) == len(con_names) + 1 + + tmp = list() + for i in colind: + s = int(i.text) + n = int(i.attrib.pop('mult', 1)) - 1 + step = int(i.attrib.pop('incr', 0)) + tmp.append(s) + for _ in range(n): + s += step + tmp.append(s) + colind = tmp + + tmp = list() + for i in vals: + s = float(i.text) + n = int(i.attrib.pop('mult', 1)) + for _ in range(n): + tmp.append(s) + vals = tmp + + linear_parts = list() + for row in range(len(con_names)): + if rowstart[row] == rowstart[row + 1]: + linear_parts.append(0) + else: + coefs = vals[rowstart[row] : rowstart[row + 1]] + var_indices = colind[rowstart[row] : rowstart[row + 1]] + _vars = [m.vars[vnames[i]] for i in var_indices] + linear_parts.append( + LinearExpression(constant=0, linear_coefs=coefs, linear_vars=_vars) + ) + else: + linear_parts = [0] * len(con_names) + + quad_exprs = [0] * len(con_names) + obj_expr = 0 + if (ns + 'quadraticCoefficients') in instance_data_nodes: + quadpart = list(instance_data.iter(ns + 'quadraticCoefficients')) + assert len(quadpart) == 1 + quadpart = quadpart[0] + for i in quadpart: + row_ndx = int(i.attrib['idx']) + col1 = int(i.attrib['idxOne']) + col2 = int(i.attrib['idxTwo']) + v1 = m.vars[vnames[col1]] + v2 = m.vars[vnames[col2]] + coef = float(i.attrib['coef']) + if row_ndx == -1: + obj_expr += coef * (v1 * v2) + else: + quad_exprs[row_ndx] += coef * (v1 * v2) + + var_map = dict() + for var_ndx, var_name in enumerate(vnames): + var_map[var_ndx] = m.vars[var_name] + nl_exprs = [0] * len(con_names) + if (ns + 'nonlinearExpressions') in instance_data_nodes: + nlpart = list(instance_data.iter(ns + 'nonlinearExpressions')) + assert len(nlpart) == 1 + nlpart = nlpart[0] + for i in nlpart: + row_ndx = int(i.attrib['idx']) + assert len(i) == 1 + expr = _parse_nonlinear_expression_osil(i[0], var_map) + if row_ndx == -1: + obj_expr += expr + else: + nl_exprs[row_ndx] = expr + + m.con_names = pe.Set(initialize=con_names) + m.cons = IndexedConstraint(m.con_names) + for ndx, cname in enumerate(con_names): + l = linear_parts[ndx] + q = quad_exprs[ndx] + n = nl_exprs[ndx] + lb = con_lbs[ndx] + ub = con_ubs[ndx] + if lb == ub and lb is not None: + m.cons[cname] = l + q + n == lb + else: + m.cons[cname] = (lb, l + q + n, ub) + + if (ns + 'objectives') in instance_data_nodes: + obj_node = list(instance_data.iter(ns + 'objectives')) + assert len(obj_node) == 1 + obj_node = obj_node[0] + # yes - this really does need repeated + assert len(obj_node) == 1 + obj_node = obj_node[0] + sense_str = obj_node.attrib['maxOrMin'] + if sense_str == 'min': + sense = pe.minimize + else: + assert sense_str == 'max' + sense = pe.maximize + + lin_coefs = list() + lin_vars = list() + obj_const = float(obj_node.attrib.pop('constant', 0)) + for node in obj_node: + var_ndx = int(node.attrib['idx']) + var_name = vnames[var_ndx] + coef = float(node.text) + lin_coefs.append(coef) + lin_vars.append(m.vars[var_name]) + if len(lin_coefs) > 0: + obj_expr += LinearExpression( + constant=obj_const, linear_coefs=lin_coefs, linear_vars=lin_vars + ) + else: + obj_expr += obj_const + else: + sense = pe.minimize + + m.objective = pe.Objective(expr=obj_expr, sense=sense) + + return m diff --git a/pyomo/contrib/coramin/third_party/tests/__init__.py b/pyomo/contrib/coramin/third_party/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/third_party/tests/test_minlplib_tools.py b/pyomo/contrib/coramin/third_party/tests/test_minlplib_tools.py new file mode 100644 index 00000000000..52bc1e43f02 --- /dev/null +++ b/pyomo/contrib/coramin/third_party/tests/test_minlplib_tools.py @@ -0,0 +1,342 @@ +from pyomo.contrib import coramin +from pyomo.common import unittest +import os +from pyomo.common.fileutils import this_file_dir +from urllib.request import urlopen +from socket import timeout +import shutil + +try: + urlopen('http://www.minlplib.org', timeout=3) +except timeout: + raise unittest.SkipTest('an internet connection is required to test minlplib_tools') + + +class TestMINLPLibTools(unittest.TestCase): + @classmethod + def tearDownClass(self) -> None: + current_dir = this_file_dir() + print(current_dir) + if os.path.exists(os.path.join(current_dir, 'minlplib')): + shutil.rmtree(os.path.join(current_dir, 'minlplib')) + + def test_get_minlplib_instancedata(self): + current_dir = this_file_dir() + fname = os.path.join(current_dir, 'minlplib', 'instancedata.csv') + coramin.third_party.get_minlplib_instancedata(target_filename=fname) + self.assertTrue( + os.path.exists(os.path.join(current_dir, 'minlplib', 'instancedata.csv')) + ) + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=fname + ) + self.assertEqual(len(cases), 1595 + 7) + os.remove(os.path.join(current_dir, 'minlplib', 'instancedata.csv')) + os.rmdir(os.path.join(current_dir, 'minlplib')) + + def test_filter_minlplib_instances(self): + current_dir = this_file_dir() + coramin.third_party.get_minlplib_instancedata( + target_filename=os.path.join(current_dir, 'minlplib', 'instancedata.csv') + ) + + total_cases = 1595 + 7 + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + acceptable_formats='osil', + acceptable_probtype='QCQP', + min_njacobiannz=1000, + max_njacobiannz=10000, + ) + self.assertEqual(len(cases), 6) # regression + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + acceptable_formats=['osil', 'gms'], + ) + self.assertEqual(len(cases), total_cases) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ) + ) + self.assertEqual(len(cases), total_cases) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + acceptable_probtype=['QCQP', 'MIQCQP', 'MBQCQP'], + ) + self.assertEqual(len(cases), 56 + 2) # regression + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + acceptable_objtype='linear', + acceptable_objcurvature='linear', + acceptable_conscurvature='convex', + acceptable_convex=True, + ) + self.assertEqual(len(cases), 280) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + acceptable_convex=[True], + ) + self.assertEqual(len(cases), 377) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + min_nvars=2, + max_nvars=200000, + ) + self.assertEqual(len(cases), total_cases - 16 - 1 - 2) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nbinvars=31000, + ) + self.assertEqual(len(cases), total_cases - 1) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nintvars=1999, + ) + self.assertEqual(len(cases), total_cases - 1) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_ncons=164000, + ) + self.assertEqual(len(cases), total_cases - 4) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nsemi=13, + ) + self.assertEqual(len(cases), total_cases) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nsos1=0, + max_nsos2=0, + ) + self.assertEqual(len(cases), total_cases - 6) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nnlvars=199998, + ) + self.assertEqual(len(cases), total_cases - 2) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nnlbinvars=23867, + ) + self.assertEqual(len(cases), total_cases - 1) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nnlintvars=1999, + ) + self.assertEqual(len(cases), total_cases - 1) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nobjnz=99997, + ) + self.assertEqual(len(cases), total_cases - 1) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nobjnlnz=99997, + ) + self.assertEqual(len(cases), total_cases - 1) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nlincons=164319, + ) + self.assertEqual(len(cases), total_cases - 3) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nquadcons=139999, + ) + self.assertEqual(len(cases), total_cases - 2) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_npolynomcons=13975, + ) + self.assertEqual(len(cases), total_cases - 1) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nsignomcons=801, + ) + self.assertEqual(len(cases), total_cases - 1) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_ngennlcons=13975, + ) + self.assertEqual(len(cases), total_cases - 2) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_njacobiannlnz=1623023, + ) + self.assertEqual(len(cases), total_cases - 1) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nlaghessiannz=1825419, + ) + self.assertEqual(len(cases), total_cases - 1) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + max_nlaghessiandiagnz=100000, + ) + self.assertEqual(len(cases), total_cases - 3) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + min_nnlsemi=1, + ) + self.assertEqual(len(cases), 0) # unit + + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=os.path.join( + current_dir, 'minlplib', 'instancedata.csv' + ), + acceptable_objcurvature=['linear', 'convex'], + ) + self.assertEqual(len(cases), 1220 + 7) # unit + + os.remove(os.path.join(current_dir, 'minlplib', 'instancedata.csv')) + os.rmdir(os.path.join(current_dir, 'minlplib')) + + def test_get_minlplib(self): + current_dir = this_file_dir() + coramin.third_party.get_minlplib( + download_dir=os.path.join(current_dir, 'minlplib', 'osil') + ) + files = os.listdir(os.path.join(current_dir, 'minlplib', 'osil')) + self.assertEqual(len(files), 1594 + 7) + for i in files: + self.assertTrue(i.endswith('.osil')) + for i in os.listdir(os.path.join(current_dir, 'minlplib', 'osil')): + os.remove(os.path.join(current_dir, 'minlplib', 'osil', i)) + os.rmdir(os.path.join(current_dir, 'minlplib', 'osil')) + os.rmdir(os.path.join(current_dir, 'minlplib')) + + def test_get_minlplib_problem(self): + current_dir = this_file_dir() + coramin.third_party.get_minlplib( + download_dir=os.path.join(current_dir, 'minlplib', 'gms'), + format='gms', + problem_name='ex4_1_1', + ) + files = os.listdir(os.path.join(current_dir, 'minlplib', 'gms')) + self.assertEqual(len(files), 1) + self.assertEqual(files[0], 'ex4_1_1.gms') + os.remove(os.path.join(current_dir, 'minlplib', 'gms', files[0])) + os.rmdir(os.path.join(current_dir, 'minlplib', 'gms')) + os.rmdir(os.path.join(current_dir, 'minlplib')) + + +class TestExceptions(unittest.TestCase): + def test_exceptions1(self): + current_dir = this_file_dir() + filename = os.path.join(current_dir, 'instancedata.csv') + f = open(filename, 'w') + f.write('blah') + f.close() + + with self.assertRaises(ValueError): + coramin.third_party.get_minlplib_instancedata(target_filename=filename) + + f = open(filename, 'r') + self.assertEqual(f.read(), 'blah') + os.remove(filename) + + def test_exceptions2(self): + current_dir = this_file_dir() + filename = os.path.join(current_dir, 'minlplib', 'instancedata.csv') + coramin.third_party.get_minlplib_instancedata(target_filename=filename) + + with self.assertRaises(ValueError): + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=filename, acceptable_probtype='foo' + ) + + with self.assertRaises(ValueError): + cases = coramin.third_party.filter_minlplib_instances( + instancedata_filename=filename, acceptable_probtype=['QCQP', 'foo'] + ) + + os.remove(filename) + os.rmdir(os.path.dirname(filename)) + + def test_exceptions3(self): + current_dir = this_file_dir() + os.makedirs(os.path.join(current_dir, 'minlplib', 'osil')) + with self.assertRaises(ValueError): + coramin.third_party.get_minlplib( + download_dir=os.path.join(current_dir, 'minlplib', 'osil') + ) + files = os.listdir(os.path.join(current_dir, 'minlplib', 'osil')) + self.assertEqual(len(files), 0) + os.rmdir(os.path.join(current_dir, 'minlplib', 'osil')) + os.rmdir(os.path.join(current_dir, 'minlplib')) diff --git a/pyomo/contrib/coramin/utils/__init__.py b/pyomo/contrib/coramin/utils/__init__.py new file mode 100644 index 00000000000..2c4f566a830 --- /dev/null +++ b/pyomo/contrib/coramin/utils/__init__.py @@ -0,0 +1,2 @@ +from .coramin_enums import FunctionShape, RelaxationSide, Effort, EigenValueBounder +from .pyomo_utils import get_objective, simplify_expr diff --git a/pyomo/contrib/coramin/utils/compare_models.py b/pyomo/contrib/coramin/utils/compare_models.py new file mode 100644 index 00000000000..8c5f526dac6 --- /dev/null +++ b/pyomo/contrib/coramin/utils/compare_models.py @@ -0,0 +1,180 @@ +import pyomo.environ as pe +from pyomo.contrib.coramin.clone import clone_shallow_active_flat +from pyomo.core.base.block import _BlockData +from typing import Optional +from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr +from pyomo.contrib import appsi +from .pyomo_utils import active_vars, active_cons, simplify_expr +from pyomo.common.collections import ComponentSet, ComponentMap, OrderedSet +from pyomo.core.expr.visitor import identify_variables, replace_expressions +from pyomo.repn.standard_repn import StandardRepn, generate_standard_repn +from pyomo.common.modeling import unique_component_name + + +def _attempt_presolve(m, vars_to_presolve): + vars_to_presolve = ComponentSet(vars_to_presolve) + var_to_con_map = ComponentMap() + for v in vars_to_presolve: + var_to_con_map[v] = OrderedSet() + for c in active_cons(m): + for v in identify_variables(c.body, include_fixed=False): + if v in vars_to_presolve: + var_to_con_map[v].add(c) + cname = unique_component_name(m, 'bound_constraints') + bound_cons = pe.ConstraintList() + setattr(m, cname, bound_cons) + for v in list(var_to_con_map.keys()): + con_list = var_to_con_map[v] + v_expr = None + v_con = None + v_repn = None + v_vars = None + density = None + for c in con_list: + if not c.equality: + continue + if not c.active: + continue + repn: StandardRepn = generate_standard_repn( + c.body - c.lb, compute_values=True, quadratic=False + ) + lin_vars = ComponentSet(repn.linear_vars) + nonlin_vars = ComponentSet(repn.nonlinear_vars) + if v in lin_vars and v not in nonlin_vars: + n_vars = len( + ComponentSet(list(repn.linear_vars) + list(repn.nonlinear_vars)) + ) + if density is None or n_vars < density: + v_expr = -repn.constant + for coef, other in zip(repn.linear_coefs, repn.linear_vars): + if v is other: + v_coef = coef + else: + v_expr -= coef * other + if repn.nonlinear_expr is not None: + v_expr -= repn.nonlinear_expr + v_expr /= v_coef + v_con = c + v_repn = repn + v_vars = ComponentSet( + [i for i in v_repn.linear_vars if i in var_to_con_map] + ) + v_vars.update( + [i for i in v_repn.nonlinear_vars if i in var_to_con_map] + ) + v_vars.remove(v) + density = n_vars + if v_expr is None: + return False + + v_con.deactivate() + + if v.lb is not None or v.ub is not None: + new_con = bound_cons.add((v.lb, v_expr, v.ub)) + for _v in v_vars: + var_to_con_map[_v].add(new_con) + + for c in con_list: + if c is v_con: + continue + sub_map = {id(v): v_expr} + new_body = simplify_expr( + replace_expressions(c.body, substitution_map=sub_map) + ) + c.set_value((c.lb, new_body, c.ub)) + for _v in v_vars: + var_to_con_map[_v].add(c) + + return True + + +def is_relaxation( + a: _BlockData, + b: _BlockData, + opt: appsi.base.Solver, + feasibility_tol: float = 1e-6, + bigM: Optional[float] = None, +): + """ + Returns True if every feasible point in b is feasible for a + (a is a relaxation of b) + a and b should share variables + """ + + """ + if a has variables that b does not, this will not work + see if we can presolve them out + Note - it is okay if b has variables that a does not + """ + a_vars = ComponentSet(active_vars(a)) + b_vars = ComponentSet(active_vars(b)) + vars_to_presolve = a_vars - b_vars + if len(vars_to_presolve) > 0: + a = clone_shallow_active_flat(a)[0] + if not _attempt_presolve(a, vars_to_presolve): + raise RuntimeError( + 'a has variables that b does not, which makes the following analysis invalid' + ) + + m = clone_shallow_active_flat(b)[0] + if hasattr(m.linear, 'obj'): + del m.linear.obj + if hasattr(m.nonlinear, 'obj'): + del m.nonlinear.obj + + m.max_viol = pe.Var(bounds=(None, 1)) + m.con_viol = pe.VarList() + m.is_max = pe.VarList(domain=pe.Binary) + m.max_viol_cons = pe.ConstraintList() + u_y_pairs = list() + default_M = bigM + if default_M is None: + bigM = 0 + else: + bigM = default_M + for con in a.component_data_objects(pe.Constraint, descend_into=True, active=True): + elist = list() + if con.ub is not None: + elist.append(con.body - con.ub) + if con.lb is not None: + elist.append(-con.body + con.lb) + for e in elist: + u = m.con_viol.add() + y = m.is_max.add() + m.max_viol_cons.add(u <= e) + u_y_pairs.append((u, y)) + if default_M is None: + u_lb = compute_bounds_on_expr(e)[0] + if u_lb is None: + raise RuntimeError('could not compute big M value') + if u_lb > feasibility_tol: + return False + if u_lb < 0: + bigM = max(bigM, abs(u_lb)) + m.max_viol_cons.add(sum(m.is_max.values()) == 1) + for u, y in u_y_pairs: + m.max_viol_cons.add(m.max_viol <= u + (1 - y) * bigM) + m.obj = pe.Objective(expr=m.max_viol, sense=pe.maximize) + + res = opt.solve(m) + assert res.termination_condition == appsi.base.TerminationCondition.optimal + + passed = res.best_feasible_objective <= feasibility_tol + + return passed + + +def is_equivalent( + a: _BlockData, + b: _BlockData, + opt: appsi.base.Solver, + feasibility_tol: float = 1e-6, + bigM: Optional[float] = None, +): + """ + Returns True if the feasible regions of a and b are the same + a and b should share variables + """ + cond1 = is_relaxation(a, b, opt=opt, feasibility_tol=feasibility_tol, bigM=bigM) + cond2 = is_relaxation(b, a, opt=opt, feasibility_tol=feasibility_tol, bigM=bigM) + return cond1 and cond2 diff --git a/pyomo/contrib/coramin/utils/coramin_enums.py b/pyomo/contrib/coramin/utils/coramin_enums.py new file mode 100644 index 00000000000..6e997631df5 --- /dev/null +++ b/pyomo/contrib/coramin/utils/coramin_enums.py @@ -0,0 +1,30 @@ +from enum import IntEnum, Enum + + +class EigenValueBounder(IntEnum): + Gershgorin = 1 + GershgorinWithSimplification = 2 + LinearProgram = 3 + Global = 4 + + +class Effort(IntEnum): + none = 0 + very_low = 1 + low = 2 + medium = 3 + high = 4 + very_high = 5 + + +class RelaxationSide(IntEnum): + UNDER = 1 + OVER = 2 + BOTH = 3 + + +class FunctionShape(IntEnum): + LINEAR = 1 + CONVEX = 2 + CONCAVE = 3 + UNKNOWN = 4 diff --git a/pyomo/contrib/coramin/utils/mpi_utils.py b/pyomo/contrib/coramin/utils/mpi_utils.py new file mode 100644 index 00000000000..c711c8036f2 --- /dev/null +++ b/pyomo/contrib/coramin/utils/mpi_utils.py @@ -0,0 +1,112 @@ +from mpi4py import MPI +from pyomo.common.dependencies import numpy as np +import sys +import os + + +class MPISyncError(Exception): + pass + + +class MPIInterface: + def __init__(self): + self._comm = MPI.COMM_WORLD + self._size = self._comm.Get_size() + self._rank = self._comm.Get_rank() + + @property + def comm(self): + return self._comm + + @property + def rank(self): + return self._rank + + @property + def size(self): + return self._size + + +class MPIAllocationMap: + def __init__(self, mpi_interface, global_N): + self._mpi_interface = mpi_interface + self._global_N = global_N + + rank = self._mpi_interface.rank + size = self._mpi_interface.size + + # there must be a better way to do this + # find which entries in global correspond + # to this process (want them to be contiguous + # for the MPI Allgather calls later + local_N = [0 for i in range(self._mpi_interface.size)] + for i in range(global_N): + process_i = i % size + local_N[process_i] += 1 + + start = 0 + end = None + for i, v in enumerate(local_N): + if i == self._mpi_interface.rank: + end = start + v + break + else: + start += v + + self._local_map = list(range(start, end)) + + def local_allocation_map(self): + return list(self._local_map) + + def local_list(self, global_data): + local_data = list() + assert len(global_data) == self._global_N + for i in self._local_map: + local_data.append(global_data[i]) + return local_data + + def global_list_float64(self, local_data_float64): + assert len(local_data_float64) == len(self._local_map) + global_data_numpy = np.zeros(self._global_N, dtype='d') * np.nan + local_data_numpy = np.asarray(local_data_float64, dtype='d') + comm = self._mpi_interface.comm + comm.Allgatherv([local_data_numpy, MPI.DOUBLE], [global_data_numpy, MPI.DOUBLE]) + + return global_data_numpy.tolist() + + +def activate_mpi_printing(style='rank-0-console', rank_0_filename='output_rank_0.txt'): + """ + Redirect standard output based on process rank. + + Parameters + ---------- + style: str + Can be set to one of: + * 'ignore-all': ignore all printing (actually, redirect all printing to os.devnull) + * 'rank-0-console': printing from rank 0 will go to the console, + printing from other processes will be ignored + * 'rank-0-console-x-files': printing from rank 0 will go to the console, + printing from other processes will go to a separate file ('output_rank_x.txt') + * 'rank-0-file': printing from rank 0 will go to 'output_rank_0.txt' + * 'separate-files': printing from each processor will be redirected to a separate + file for each process ('output_rank_x.txt') + """ + rank = MPIInterface().rank + if style == 'ignore-all': + sys.stdout = open(os.devnull, 'w') + elif style == 'rank-0-console': + if rank != 0: + sys.stdout = open(os.devnull, 'w') + elif style == 'rank-0-file': + if rank == 0: + sys.stdout = open(rank_0_filename, 'w') + else: + sys.stdout = open(os.devnull, 'w') + elif style == 'rank-0-console-x-files': + if rank != 0: + sys.stdout = open( + 'output_rank_{0}.txt'.format(str(MPIInterface().rank)), 'w' + ) + elif style == 'separate-files': + sys.stdout = open('output_rank_{0}.txt'.format(str(MPIInterface().rank)), 'w') diff --git a/pyomo/contrib/coramin/utils/plot_relaxation.py b/pyomo/contrib/coramin/utils/plot_relaxation.py new file mode 100644 index 00000000000..f5f4baef0c7 --- /dev/null +++ b/pyomo/contrib/coramin/utils/plot_relaxation.py @@ -0,0 +1,224 @@ +from pyomo.common.dependencies import numpy as np + +try: + import plotly.graph_objects as go +except ImportError: + pass +import pyomo.environ as pe +from .pyomo_utils import get_objective +from .coramin_enums import RelaxationSide +from pyomo.solvers.plugins.solvers.persistent_solver import PersistentSolver + +try: + import tqdm +except ImportError: + tqdm = None + + +def _solve(m, using_persistent_solver, solver, rhs_vars, aux_var, obj): + obj.activate() + if using_persistent_solver: + for v in rhs_vars: + solver.update_var(v) + solver.set_objective(obj) + res = solver.solve(load_solutions=False, save_results=False) + else: + res = solver.solve(m, load_solutions=False) + if res.solver.termination_condition != pe.TerminationCondition.optimal: + raise RuntimeError( + 'Could not produce plot because solver did not terminate optimally' + ) + if using_persistent_solver: + solver.load_vars([aux_var]) + else: + m.solutions.load_from(res) + obj.deactivate() + + +def _solve_loop(m, x, w, x_list, using_persistent_solver, solver): + w_list = list() + for _xval in x_list: + x.fix(_xval) + if using_persistent_solver: + solver.update_var(x) + res = solver.solve(load_solutions=False, save_results=False) + else: + res = solver.solve(m, load_solutions=False) + if res.solver.termination_condition != pe.TerminationCondition.optimal: + raise RuntimeError( + 'Could not produce plot because solver did not terminate optimally. Termination condition: ' + + str(res.solver.termination_condition) + ) + if using_persistent_solver: + solver.load_vars([w]) + else: + m.solutions.load_from(res) + w_list.append(w.value) + return w_list + + +def _plot_2d(m, relaxation, solver, show_plot, num_pts): + using_persistent_solver = isinstance(solver, PersistentSolver) + + x = relaxation.get_rhs_vars()[0] + w = relaxation.get_aux_var() + + if not x.has_lb() or not x.has_ub(): + raise ValueError('rhs var must have bounds') + + orig_xval = x.value + orig_wval = w.value + xlb = pe.value(x.lb) + xub = pe.value(x.ub) + + orig_obj = get_objective(m) + if orig_obj is not None: + orig_obj.deactivate() + + x_list = np.linspace(xlb, xub, num_pts) + x_list = [float(i) for i in x_list] + w_true = list() + + rhs_expr = relaxation.get_rhs_expr() + for _x in x_list: + x.value = float(_x) + w_true.append(pe.value(rhs_expr)) + plotly_data = [go.Scatter(x=x_list, y=w_true, name=str(rhs_expr))] + + m._plotting_objective = pe.Objective(expr=w) + if using_persistent_solver: + solver.set_instance(m) + + if relaxation.relaxation_side in {RelaxationSide.UNDER, RelaxationSide.BOTH}: + w_min = _solve_loop(m, x, w, x_list, using_persistent_solver, solver) + plotly_data.append(go.Scatter(x=x_list, y=w_min, name='underestimator')) + + del m._plotting_objective + m._plotting_objective = pe.Objective(expr=w, sense=pe.maximize) + if using_persistent_solver: + solver.set_objective(m._plotting_objective) + + if relaxation.relaxation_side in {RelaxationSide.OVER, RelaxationSide.BOTH}: + w_max = _solve_loop(m, x, w, x_list, using_persistent_solver, solver) + plotly_data.append(go.Scatter(x=x_list, y=w_max, name='overestimator')) + + fig = go.Figure(data=plotly_data) + if show_plot: + fig.show() + + x.unfix() + x.value = orig_xval + w.value = orig_wval + del m._plotting_objective + if orig_obj is not None: + orig_obj.activate() + + +def _plot_3d(m, relaxation, solver, show_plot, num_pts): + using_persistent_solver = isinstance(solver, PersistentSolver) + + rhs_vars = relaxation.get_rhs_vars() + x, y = rhs_vars + w = relaxation.get_aux_var() + + if not x.has_lb() or not x.has_ub() or not y.has_lb() or not y.has_ub(): + raise ValueError('rhs vars must have bounds') + + orig_xval = x.value + orig_yval = y.value + orig_wval = w.value + + orig_obj = get_objective(m) + if orig_obj is not None: + orig_obj.deactivate() + + m._underestimator_obj = pe.Objective(expr=w) + m._overestimator_obj = pe.Objective(expr=w, sense=pe.maximize) + m._underestimator_obj.deactivate() + m._overestimator_obj.deactivate() + if using_persistent_solver: + solver.set_instance(m) + + x_list = np.linspace(x.lb, x.ub, num_pts) + y_list = np.linspace(y.lb, y.ub, num_pts) + x_list = [float(i) for i in x_list] + y_list = [float(i) for i in y_list] + w_true = np.empty((num_pts, num_pts), dtype=np.double) + w_min = np.empty((num_pts, num_pts), dtype=np.double) + w_max = np.empty((num_pts, num_pts), dtype=np.double) + + rhs_expr = relaxation.get_rhs_expr() + + def sub_loop(x_ndx, _x): + x.fix(_x) + for y_ndx, _y in enumerate(y_list): + y.fix(_y) + w_true[x_ndx, y_ndx] = pe.value(rhs_expr) + if relaxation.relaxation_side in { + RelaxationSide.UNDER, + RelaxationSide.BOTH, + }: + _solve( + m, + using_persistent_solver, + solver, + rhs_vars, + w, + m._underestimator_obj, + ) + w_min[x_ndx, y_ndx] = w.value + if relaxation.relaxation_side in {RelaxationSide.OVER, RelaxationSide.BOTH}: + _solve( + m, + using_persistent_solver, + solver, + rhs_vars, + w, + m._overestimator_obj, + ) + w_max[x_ndx, y_ndx] = w.value + + if tqdm is not None: + for x_ndx, _x in tqdm.tqdm(list(enumerate(x_list))): + sub_loop(x_ndx, _x) + else: + for x_ndx, _x in enumerate(x_list): + sub_loop(x_ndx, _x) + + plotly_data = list() + plotly_data.append(go.Surface(x=x_list, y=y_list, z=w_true, name=str(rhs_expr))) + if relaxation.relaxation_side in {RelaxationSide.UNDER, RelaxationSide.BOTH}: + plotly_data.append( + go.Surface(x=x_list, y=y_list, z=w_min, name='underestimator') + ) + if relaxation.relaxation_side in {RelaxationSide.OVER, RelaxationSide.BOTH}: + plotly_data.append( + go.Surface(x=x_list, y=y_list, z=w_max, name='overestimator') + ) + + fig = go.Figure(data=plotly_data) + if show_plot: + fig.show() + + x.unfix() + y.unfix() + x.value = orig_xval + y.value = orig_yval + w.value = orig_wval + del m._underestimator_obj + del m._overestimator_obj + if orig_obj is not None: + orig_obj.activate() + + +def plot_relaxation(m, relaxation, solver, show_plot=True, num_pts=100): + rhs_vars = relaxation.get_rhs_vars() + + if len(rhs_vars) == 1: + _plot_2d(m, relaxation, solver, show_plot, num_pts) + elif len(rhs_vars) == 2: + _plot_3d(m, relaxation, solver, show_plot, num_pts) + else: + raise NotImplementedError( + 'Cannot generate plot for relaxation with more than 2 RHS vars' + ) diff --git a/pyomo/contrib/coramin/utils/pyomo_utils.py b/pyomo/contrib/coramin/utils/pyomo_utils.py new file mode 100644 index 00000000000..9c39c5ef532 --- /dev/null +++ b/pyomo/contrib/coramin/utils/pyomo_utils.py @@ -0,0 +1,75 @@ +import pyomo.environ as pe +from pyomo.core.expr.numvalue import is_fixed +from pyomo.core.expr.visitor import identify_variables +from pyomo.core.base.constraint import _GeneralConstraintData +from pyomo.contrib.simplification import Simplifier +from weakref import WeakKeyDictionary + + +def get_objective(m): + """ + Assert that there is only one active objective in m and return it. + + Parameters + ---------- + m: pyomo.core.base.block._BlockData + + Returns + ------- + obj: pyomo.core.base.objective._ObjectiveData + """ + obj = None + for o in m.component_data_objects( + pe.Objective, descend_into=True, active=True, sort=True + ): + if obj is not None: + raise ValueError('Found multiple active objectives') + obj = o + return obj + + +_var_cache = WeakKeyDictionary() + + +def identify_variables_with_cache(con: _GeneralConstraintData, include_fixed=False): + e = con.expr + if con in _var_cache and _var_cache[con][1] is e: + vlist = _var_cache[con][0] + else: + vlist = list(identify_variables(e, include_fixed=True)) + if not include_fixed: + vlist = [i for i in vlist if not i.fixed] + _var_cache[con] = (vlist, e) + return vlist + + +def active_vars(m, include_fixed=False): + seen = set() + for c in m.component_data_objects(pe.Constraint, active=True, descend_into=True): + for v in identify_variables_with_cache(c, include_fixed=include_fixed): + v_id = id(v) + if v_id not in seen: + seen.add(v_id) + yield v + obj = get_objective(m) + if obj is not None: + for v in identify_variables(obj.expr, include_fixed=include_fixed): + v_id = id(v) + if v_id not in seen: + seen.add(v_id) + yield v + + +def active_cons(m): + for c in m.component_data_objects(pe.Constraint, active=True, descend_into=True): + yield c + + +simplifier = Simplifier() + + +def simplify_expr(expr): + new_expr = simplifier.simplify(expr) + if is_fixed(new_expr): + new_expr = pe.value(new_expr) + return new_expr diff --git a/pyomo/contrib/coramin/utils/tests/__init__.py b/pyomo/contrib/coramin/utils/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/coramin/utils/tests/test_compare_models.py b/pyomo/contrib/coramin/utils/tests/test_compare_models.py new file mode 100644 index 00000000000..79b582b04fe --- /dev/null +++ b/pyomo/contrib/coramin/utils/tests/test_compare_models.py @@ -0,0 +1,120 @@ +import pyomo.environ as pe +from pyomo.contrib import appsi +from pyomo.common import unittest +from pyomo.contrib.coramin.utils.compare_models import ( + is_relaxation, + is_equivalent, + _attempt_presolve, +) +from pyomo.core.expr.compare import assertExpressionsEqual, compare_expressions + + +highs_available = appsi.solvers.Highs().available() + + +class TestCompareModels(unittest.TestCase): + @unittest.skipUnless(highs_available, 'HiGHS is not available') + def test_compare_models_1(self): + m1 = pe.ConcreteModel() + m1.x = x = pe.Var(bounds=(-5, 4)) + m1.y = y = pe.Var(bounds=(0, 7)) + + m1.c1 = pe.Constraint(expr=x + y == 1) + + m2 = pe.ConcreteModel() + m2.c1 = pe.Constraint(expr=x + y <= 1) + m2.c2 = pe.Constraint(expr=x + y >= 1) + + opt = appsi.solvers.Highs() + + self.assertTrue(is_equivalent(m1, m2, opt)) + m2.c2.deactivate() + self.assertFalse(is_equivalent(m1, m2, opt)) + self.assertTrue(is_relaxation(m2, m1, opt)) + self.assertFalse(is_relaxation(m1, m2, opt)) + + def _get_model(self): + m = pe.ConcreteModel() + m.x1 = pe.Var() + m.x2 = pe.Var() + m.x3 = pe.Var(bounds=(-3, 3)) + m.x4 = pe.Var() + m.c1 = pe.Constraint(expr=m.x1 + m.x2 + m.x3 + m.x4 == 1) + m.c2 = pe.Constraint(expr=m.x2 + m.x3 + m.x4 == 1) + m.c3 = pe.Constraint(expr=m.x3 + m.x4 == 1) + m.c4 = pe.Constraint(expr=m.x4 == 1) + return m + + def _compare_expressions(self, got, options): + success = False + for exp in options: + if compare_expressions(got, exp): + success = True + break + return success + + def test_presolve1(self): + m = self._get_model() + success = _attempt_presolve(m, [m.x3]) + self.assertTrue(success) + self.assertTrue(m.c1.active) + self.assertTrue(m.c2.active) + self.assertFalse(m.c3.active) + self.assertTrue(m.c4.active) + self.assertTrue( + self._compare_expressions( + m.c1.body, + [m.x1 + m.x2 + 1, m.x2 + m.x1 + 1, 1 + m.x1 + m.x2, 1 + m.x2 + m.x1], + ) + ) + self.assertTrue(self._compare_expressions(m.c2.body, [m.x2 + 1, 1 + m.x2])) + self.assertEqual(m.c1.lb, 1) + self.assertEqual(m.c1.ub, 1) + self.assertEqual(m.c2.lb, 1) + self.assertEqual(m.c2.ub, 1) + self.assertEqual(len(m.bound_constraints), 1) + self.assertTrue( + self._compare_expressions(m.bound_constraints[1].body, [1 - m.x4]) + ) + self.assertEqual(m.bound_constraints[1].lb, -3) + self.assertEqual(m.bound_constraints[1].ub, 3) + + def test_presolve2(self): + m = self._get_model() + success = _attempt_presolve(m, [m.x3, m.x4]) + self.assertTrue(success) + self.assertTrue(m.c1.active) + self.assertTrue(m.c2.active) + self.assertFalse(m.c3.active) + self.assertFalse(m.c4.active) + self.assertTrue( + self._compare_expressions( + m.c1.body, + [m.x1 + m.x2 + 1, m.x2 + m.x1 + 1, 1 + m.x1 + m.x2, 1 + m.x2 + m.x1], + ) + ) + self.assertTrue(self._compare_expressions(m.c2.body, [m.x2 + 1, 1 + m.x2])) + self.assertEqual(m.c1.lb, 1) + self.assertEqual(m.c1.ub, 1) + self.assertEqual(m.c2.lb, 1) + self.assertEqual(m.c2.ub, 1) + self.assertEqual(len(m.bound_constraints), 1) + self.assertEqual(m.bound_constraints[1].body, 0) + self.assertEqual(m.bound_constraints[1].lb, -3) + self.assertEqual(m.bound_constraints[1].ub, 3) + + def test_presolve3(self): + m = self._get_model() + success = _attempt_presolve(m, [m.x3, m.x4, m.x2]) + self.assertTrue(success) + self.assertTrue(m.c1.active) + self.assertFalse(m.c2.active) + self.assertFalse(m.c3.active) + self.assertFalse(m.c4.active) + self.assertTrue(self._compare_expressions(m.c1.body, [m.x1 + 1, 1 + m.x1])) + self.assertEqual(m.c1.lb, 1) + self.assertEqual(m.c1.ub, 1) + self.assertEqual(len(m.bound_constraints), 1) + self.assertEqual(m.bound_constraints[1].body, 0) + self.assertEqual(m.bound_constraints[1].lb, -3) + self.assertEqual(m.bound_constraints[1].ub, 3) diff --git a/pyomo/contrib/fbbt/interval.py b/pyomo/contrib/fbbt/interval.py index a12d1a4529f..8b690f9d63f 100644 --- a/pyomo/contrib/fbbt/interval.py +++ b/pyomo/contrib/fbbt/interval.py @@ -200,7 +200,8 @@ def inv(xl, xu, feasibility_tol): def div(xl, xu, yl, yu, feasibility_tol): - return mul(xl, xu, *inv(yl, yu, feasibility_tol)) + lb, ub = mul(xl, xu, *inv(yl, yu, feasibility_tol)) + return lb, ub def power(xl, xu, yl, yu, feasibility_tol): diff --git a/pyomo/contrib/simplification/__init__.py b/pyomo/contrib/simplification/__init__.py new file mode 100644 index 00000000000..b4fa68eb386 --- /dev/null +++ b/pyomo/contrib/simplification/__init__.py @@ -0,0 +1,12 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2022 +# 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. +# ___________________________________________________________________________ + +from .simplify import Simplifier diff --git a/pyomo/contrib/simplification/build.py b/pyomo/contrib/simplification/build.py new file mode 100644 index 00000000000..d9d1e701290 --- /dev/null +++ b/pyomo/contrib/simplification/build.py @@ -0,0 +1,108 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2022 +# 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 glob +import os +import shutil +import sys +import tempfile +from distutils.dist import Distribution + +from pybind11.setup_helpers import Pybind11Extension, build_ext +from pyomo.common.envvar import PYOMO_CONFIG_DIR +from pyomo.common.fileutils import find_library, this_file_dir + + +def build_ginac_interface(args=None): + if args is None: + args = list() + dname = this_file_dir() + _sources = ['ginac_interface.cpp'] + sources = list() + for fname in _sources: + sources.append(os.path.join(dname, fname)) + + ginac_lib = find_library('ginac') + if ginac_lib is None: + raise RuntimeError( + 'could not find GiNaC library; please make sure it is in the LD_LIBRARY_PATH environment variable' + ) + ginac_lib_dir = os.path.dirname(ginac_lib) + ginac_build_dir = os.path.dirname(ginac_lib_dir) + ginac_include_dir = os.path.join(ginac_build_dir, 'include') + if not os.path.exists(os.path.join(ginac_include_dir, 'ginac', 'ginac.h')): + raise RuntimeError('could not find GiNaC include directory') + + cln_lib = find_library('cln') + if cln_lib is None: + raise RuntimeError( + 'could not find CLN library; please make sure it is in the LD_LIBRARY_PATH environment variable' + ) + cln_lib_dir = os.path.dirname(cln_lib) + cln_build_dir = os.path.dirname(cln_lib_dir) + cln_include_dir = os.path.join(cln_build_dir, 'include') + if not os.path.exists(os.path.join(cln_include_dir, 'cln', 'cln.h')): + raise RuntimeError('could not find CLN include directory') + + extra_args = ['-std=c++11'] + ext = Pybind11Extension( + 'ginac_interface', + sources=sources, + language='c++', + include_dirs=[cln_include_dir, ginac_include_dir], + library_dirs=[cln_lib_dir, ginac_lib_dir], + libraries=['cln', 'ginac'], + extra_compile_args=extra_args, + ) + + class ginacBuildExt(build_ext): + def run(self): + basedir = os.path.abspath(os.path.curdir) + if self.inplace: + tmpdir = this_file_dir() + else: + tmpdir = os.path.abspath(tempfile.mkdtemp()) + print("Building in '%s'" % tmpdir) + os.chdir(tmpdir) + try: + super(ginacBuildExt, self).run() + if not self.inplace: + library = glob.glob("build/*/ginac_interface.*")[0] + target = os.path.join( + PYOMO_CONFIG_DIR, + 'lib', + 'python%s.%s' % sys.version_info[:2], + 'site-packages', + '.', + ) + if not os.path.exists(target): + os.makedirs(target) + shutil.copy(library, target) + finally: + os.chdir(basedir) + if not self.inplace: + shutil.rmtree(tmpdir, onerror=handleReadonly) + + package_config = { + 'name': 'ginac_interface', + 'packages': [], + 'ext_modules': [ext], + 'cmdclass': {"build_ext": ginacBuildExt}, + } + + dist = Distribution(package_config) + dist.script_args = ['build_ext'] + args + dist.parse_command_line() + dist.run_command('build_ext') + + +if __name__ == '__main__': + build_ginac_interface(sys.argv[1:]) diff --git a/pyomo/contrib/simplification/ginac_interface.cpp b/pyomo/contrib/simplification/ginac_interface.cpp new file mode 100644 index 00000000000..c7a6e9e6be3 --- /dev/null +++ b/pyomo/contrib/simplification/ginac_interface.cpp @@ -0,0 +1,331 @@ +// ___________________________________________________________________________ +// +// Pyomo: Python Optimization Modeling Objects +// Copyright (c) 2008-2022 +// 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. +// ___________________________________________________________________________ + +#include "ginac_interface.hpp" + + +bool is_integer(double x) { + return std::floor(x) == x; +} + + +ex ginac_expr_from_pyomo_node( + py::handle expr, + std::unordered_map &leaf_map, + std::unordered_map &ginac_pyomo_map, + PyomoExprTypesSimp &expr_types, + bool symbolic_solver_labels + ) { + ex res; + ExprTypeSimp tmp_type = + expr_types.expr_type_map[py::type::of(expr)].cast(); + + switch (tmp_type) { + case py_float: { + double val = expr.cast(); + if (is_integer(val)) { + res = numeric((long) val); + } + else { + res = numeric(val); + } + break; + } + case var: { + long expr_id = expr_types.id(expr).cast(); + if (leaf_map.count(expr_id) == 0) { + std::string vname; + if (symbolic_solver_labels) { + vname = expr.attr("name").cast(); + } + else { + vname = "x" + std::to_string(expr_id); + } + py::object lb = expr.attr("lb"); + if (lb.is_none() || lb.cast() < 0) { + leaf_map[expr_id] = realsymbol(vname); + } + else { + leaf_map[expr_id] = possymbol(vname); + } + ginac_pyomo_map[leaf_map[expr_id]] = expr.cast(); + } + res = leaf_map[expr_id]; + break; + } + case param: { + long expr_id = expr_types.id(expr).cast(); + if (leaf_map.count(expr_id) == 0) { + std::string pname; + if (symbolic_solver_labels) { + pname = expr.attr("name").cast(); + } + else { + pname = "p" + std::to_string(expr_id); + } + leaf_map[expr_id] = realsymbol(pname); + ginac_pyomo_map[leaf_map[expr_id]] = expr.cast(); + } + res = leaf_map[expr_id]; + break; + } + case product: { + py::list pyomo_args = expr.attr("args"); + res = ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels) * ginac_expr_from_pyomo_node(pyomo_args[1], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + break; + } + case sum: { + py::list pyomo_args = expr.attr("args"); + for (py::handle arg : pyomo_args) { + res += ginac_expr_from_pyomo_node(arg, leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + } + break; + } + case negation: { + py::list pyomo_args = expr.attr("args"); + res = - ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + break; + } + case external_func: { + long expr_id = expr_types.id(expr).cast(); + if (leaf_map.count(expr_id) == 0) { + leaf_map[expr_id] = realsymbol("f" + std::to_string(expr_id)); + ginac_pyomo_map[leaf_map[expr_id]] = expr.cast(); + } + res = leaf_map[expr_id]; + break; + } + case ExprTypeSimp::power: { + py::list pyomo_args = expr.attr("args"); + res = pow(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels), ginac_expr_from_pyomo_node(pyomo_args[1], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + break; + } + case division: { + py::list pyomo_args = expr.attr("args"); + res = ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels) / ginac_expr_from_pyomo_node(pyomo_args[1], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + break; + } + case unary_func: { + std::string function_name = expr.attr("getname")().cast(); + py::list pyomo_args = expr.attr("args"); + if (function_name == "exp") + res = exp(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "log") + res = log(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "sin") + res = sin(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "cos") + res = cos(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "tan") + res = tan(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "asin") + res = asin(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "acos") + res = acos(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "atan") + res = atan(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else if (function_name == "sqrt") + res = sqrt(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + else + throw py::value_error("Unrecognized expression type: " + function_name); + break; + } + case linear: { + py::list pyomo_args = expr.attr("args"); + for (py::handle arg : pyomo_args) { + res += ginac_expr_from_pyomo_node(arg, leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + } + break; + } + case named_expr: { + res = ginac_expr_from_pyomo_node(expr.attr("expr"), leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + break; + } + case numeric_constant: { + res = numeric(expr.attr("value").cast()); + break; + } + case pyomo_unit: { + res = numeric(1.0); + break; + } + case unary_abs: { + py::list pyomo_args = expr.attr("args"); + res = abs(ginac_expr_from_pyomo_node(pyomo_args[0], leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels)); + break; + } + default: { + throw py::value_error("Unrecognized expression type: " + + expr_types.builtins.attr("str")(py::type::of(expr)) + .cast()); + break; + } + } + return res; +} + +ex pyomo_expr_to_ginac_expr( + py::handle expr, + std::unordered_map &leaf_map, + std::unordered_map &ginac_pyomo_map, + PyomoExprTypesSimp &expr_types, + bool symbolic_solver_labels + ) { + ex res = ginac_expr_from_pyomo_node(expr, leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); + return res; + } + +ex pyomo_to_ginac(py::handle expr, PyomoExprTypesSimp &expr_types) { + std::unordered_map leaf_map; + std::unordered_map ginac_pyomo_map; + ex res = ginac_expr_from_pyomo_node(expr, leaf_map, ginac_pyomo_map, expr_types, true); + return res; +} + + +class GinacToPyomoVisitor +: public visitor, + public symbol::visitor, + public numeric::visitor, + public add::visitor, + public mul::visitor, + public GiNaC::power::visitor, + public function::visitor, + public basic::visitor +{ + public: + std::unordered_map *leaf_map; + std::unordered_map node_map; + PyomoExprTypesSimp *expr_types; + + GinacToPyomoVisitor(std::unordered_map *_leaf_map, PyomoExprTypesSimp *_expr_types) : leaf_map(_leaf_map), expr_types(_expr_types) {} + ~GinacToPyomoVisitor() = default; + + void visit(const symbol& e) { + node_map[e] = leaf_map->at(e); + } + + void visit(const numeric& e) { + double val = e.to_double(); + node_map[e] = expr_types->NumericConstant(py::cast(val)); + } + + void visit(const add& e) { + size_t n = e.nops(); + py::object pe = node_map[e.op(0)]; + for (unsigned long ndx=1; ndx < n; ++ndx) { + pe = pe.attr("__add__")(node_map[e.op(ndx)]); + } + node_map[e] = pe; + } + + void visit(const mul& e) { + size_t n = e.nops(); + py::object pe = node_map[e.op(0)]; + for (unsigned long ndx=1; ndx < n; ++ndx) { + pe = pe.attr("__mul__")(node_map[e.op(ndx)]); + } + node_map[e] = pe; + } + + void visit(const GiNaC::power& e) { + py::object arg1 = node_map[e.op(0)]; + py::object arg2 = node_map[e.op(1)]; + py::object pe = arg1.attr("__pow__")(arg2); + node_map[e] = pe; + } + + void visit(const function& e) { + py::object arg = node_map[e.op(0)]; + std::string func_type = e.get_name(); + py::object pe; + if (func_type == "exp") { + pe = expr_types->exp(arg); + } + else if (func_type == "log") { + pe = expr_types->log(arg); + } + else if (func_type == "sin") { + pe = expr_types->sin(arg); + } + else if (func_type == "cos") { + pe = expr_types->cos(arg); + } + else if (func_type == "tan") { + pe = expr_types->tan(arg); + } + else if (func_type == "asin") { + pe = expr_types->asin(arg); + } + else if (func_type == "acos") { + pe = expr_types->acos(arg); + } + else if (func_type == "atan") { + pe = expr_types->atan(arg); + } + else if (func_type == "sqrt") { + pe = expr_types->sqrt(arg); + } + else { + throw py::value_error("unrecognized unary function: " + func_type); + } + node_map[e] = pe; + } + + void visit(const basic& e) { + throw py::value_error("unrecognized ginac expression type"); + } +}; + + +ex GinacInterface::to_ginac(py::handle expr) { + return pyomo_expr_to_ginac_expr(expr, leaf_map, ginac_pyomo_map, expr_types, symbolic_solver_labels); +} + +py::object GinacInterface::from_ginac(ex &ge) { + GinacToPyomoVisitor v(&ginac_pyomo_map, &expr_types); + ge.traverse_postorder(v); + return v.node_map[ge]; +} + +PYBIND11_MODULE(ginac_interface, m) { + m.def("pyomo_to_ginac", &pyomo_to_ginac); + py::class_(m, "PyomoExprTypesSimp").def(py::init<>()); + py::class_(m, "ginac_expression") + .def("expand", [](ex &ge) { + return ge.expand(); + }) + .def("normal", &ex::normal) + .def("__str__", [](ex &ge) { + std::ostringstream stream; + stream << ge; + return stream.str(); + }); + py::class_(m, "GinacInterface") + .def(py::init()) + .def("to_ginac", &GinacInterface::to_ginac) + .def("from_ginac", &GinacInterface::from_ginac); + py::enum_(m, "ExprTypeSimp") + .value("py_float", ExprTypeSimp::py_float) + .value("var", ExprTypeSimp::var) + .value("param", ExprTypeSimp::param) + .value("product", ExprTypeSimp::product) + .value("sum", ExprTypeSimp::sum) + .value("negation", ExprTypeSimp::negation) + .value("external_func", ExprTypeSimp::external_func) + .value("power", ExprTypeSimp::power) + .value("division", ExprTypeSimp::division) + .value("unary_func", ExprTypeSimp::unary_func) + .value("linear", ExprTypeSimp::linear) + .value("named_expr", ExprTypeSimp::named_expr) + .value("numeric_constant", ExprTypeSimp::numeric_constant) + .export_values(); +} diff --git a/pyomo/contrib/simplification/ginac_interface.hpp b/pyomo/contrib/simplification/ginac_interface.hpp new file mode 100644 index 00000000000..ec3663c6cc5 --- /dev/null +++ b/pyomo/contrib/simplification/ginac_interface.hpp @@ -0,0 +1,190 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define PYBIND11_DETAILED_ERROR_MESSAGES + +namespace py = pybind11; +using namespace pybind11::literals; +using namespace GiNaC; + +enum ExprTypeSimp { + py_float = 0, + var = 1, + param = 2, + product = 3, + sum = 4, + negation = 5, + external_func = 6, + power = 7, + division = 8, + unary_func = 9, + linear = 10, + named_expr = 11, + numeric_constant = 12, + pyomo_unit = 13, + unary_abs = 14 +}; + +class PyomoExprTypesSimp { +public: + PyomoExprTypesSimp() { + expr_type_map[int_] = py_float; + expr_type_map[float_] = py_float; + expr_type_map[np_int16] = py_float; + expr_type_map[np_int32] = py_float; + expr_type_map[np_int64] = py_float; + expr_type_map[np_longlong] = py_float; + expr_type_map[np_uint16] = py_float; + expr_type_map[np_uint32] = py_float; + expr_type_map[np_uint64] = py_float; + expr_type_map[np_ulonglong] = py_float; + expr_type_map[np_float16] = py_float; + expr_type_map[np_float32] = py_float; + expr_type_map[np_float64] = py_float; + expr_type_map[ScalarVar] = var; + expr_type_map[_GeneralVarData] = var; + expr_type_map[AutoLinkedBinaryVar] = var; + expr_type_map[ScalarParam] = param; + expr_type_map[_ParamData] = param; + expr_type_map[MonomialTermExpression] = product; + expr_type_map[ProductExpression] = product; + expr_type_map[NPV_ProductExpression] = product; + expr_type_map[SumExpression] = sum; + expr_type_map[NPV_SumExpression] = sum; + expr_type_map[NegationExpression] = negation; + expr_type_map[NPV_NegationExpression] = negation; + expr_type_map[ExternalFunctionExpression] = external_func; + expr_type_map[NPV_ExternalFunctionExpression] = external_func; + expr_type_map[PowExpression] = ExprTypeSimp::power; + expr_type_map[NPV_PowExpression] = ExprTypeSimp::power; + expr_type_map[DivisionExpression] = division; + expr_type_map[NPV_DivisionExpression] = division; + expr_type_map[UnaryFunctionExpression] = unary_func; + expr_type_map[NPV_UnaryFunctionExpression] = unary_func; + expr_type_map[LinearExpression] = linear; + expr_type_map[_GeneralExpressionData] = named_expr; + expr_type_map[ScalarExpression] = named_expr; + expr_type_map[Integral] = named_expr; + expr_type_map[ScalarIntegral] = named_expr; + expr_type_map[NumericConstant] = numeric_constant; + expr_type_map[_PyomoUnit] = pyomo_unit; + expr_type_map[AbsExpression] = unary_abs; + expr_type_map[NPV_AbsExpression] = unary_abs; + } + ~PyomoExprTypesSimp() = default; + py::int_ ione = 1; + py::float_ fone = 1.0; + py::type int_ = py::type::of(ione); + py::type float_ = py::type::of(fone); + py::object np = py::module_::import("numpy"); + py::type np_int16 = np.attr("int16"); + py::type np_int32 = np.attr("int32"); + py::type np_int64 = np.attr("int64"); + py::type np_longlong = np.attr("longlong"); + py::type np_uint16 = np.attr("uint16"); + py::type np_uint32 = np.attr("uint32"); + py::type np_uint64 = np.attr("uint64"); + py::type np_ulonglong = np.attr("ulonglong"); + py::type np_float16 = np.attr("float16"); + py::type np_float32 = np.attr("float32"); + py::type np_float64 = np.attr("float64"); + py::object ScalarParam = + py::module_::import("pyomo.core.base.param").attr("ScalarParam"); + py::object _ParamData = + py::module_::import("pyomo.core.base.param").attr("_ParamData"); + py::object ScalarVar = + py::module_::import("pyomo.core.base.var").attr("ScalarVar"); + py::object _GeneralVarData = + py::module_::import("pyomo.core.base.var").attr("_GeneralVarData"); + py::object AutoLinkedBinaryVar = + py::module_::import("pyomo.gdp.disjunct").attr("AutoLinkedBinaryVar"); + py::object numeric_expr = py::module_::import("pyomo.core.expr.numeric_expr"); + py::object NegationExpression = numeric_expr.attr("NegationExpression"); + py::object NPV_NegationExpression = + numeric_expr.attr("NPV_NegationExpression"); + py::object ExternalFunctionExpression = + numeric_expr.attr("ExternalFunctionExpression"); + py::object NPV_ExternalFunctionExpression = + numeric_expr.attr("NPV_ExternalFunctionExpression"); + py::object PowExpression = numeric_expr.attr("PowExpression"); + py::object NPV_PowExpression = numeric_expr.attr("NPV_PowExpression"); + py::object ProductExpression = numeric_expr.attr("ProductExpression"); + py::object NPV_ProductExpression = numeric_expr.attr("NPV_ProductExpression"); + py::object MonomialTermExpression = + numeric_expr.attr("MonomialTermExpression"); + py::object DivisionExpression = numeric_expr.attr("DivisionExpression"); + py::object NPV_DivisionExpression = + numeric_expr.attr("NPV_DivisionExpression"); + py::object SumExpression = numeric_expr.attr("SumExpression"); + py::object NPV_SumExpression = numeric_expr.attr("NPV_SumExpression"); + py::object UnaryFunctionExpression = + numeric_expr.attr("UnaryFunctionExpression"); + py::object AbsExpression = numeric_expr.attr("AbsExpression"); + py::object NPV_AbsExpression = numeric_expr.attr("NPV_AbsExpression"); + py::object NPV_UnaryFunctionExpression = + numeric_expr.attr("NPV_UnaryFunctionExpression"); + py::object LinearExpression = numeric_expr.attr("LinearExpression"); + py::object NumericConstant = + py::module_::import("pyomo.core.expr.numvalue").attr("NumericConstant"); + py::object expr_module = py::module_::import("pyomo.core.base.expression"); + py::object _GeneralExpressionData = + expr_module.attr("_GeneralExpressionData"); + py::object ScalarExpression = expr_module.attr("ScalarExpression"); + py::object ScalarIntegral = + py::module_::import("pyomo.dae.integral").attr("ScalarIntegral"); + py::object Integral = + py::module_::import("pyomo.dae.integral").attr("Integral"); + py::object _PyomoUnit = + py::module_::import("pyomo.core.base.units_container").attr("_PyomoUnit"); + py::object exp = numeric_expr.attr("exp"); + py::object log = numeric_expr.attr("log"); + py::object sin = numeric_expr.attr("sin"); + py::object cos = numeric_expr.attr("cos"); + py::object tan = numeric_expr.attr("tan"); + py::object asin = numeric_expr.attr("asin"); + py::object acos = numeric_expr.attr("acos"); + py::object atan = numeric_expr.attr("atan"); + py::object sqrt = numeric_expr.attr("sqrt"); + py::object builtins = py::module_::import("builtins"); + py::object id = builtins.attr("id"); + py::object len = builtins.attr("len"); + py::dict expr_type_map; +}; + +ex pyomo_to_ginac(py::handle expr, PyomoExprTypesSimp &expr_types); + + +class GinacInterface { + public: + std::unordered_map leaf_map; + std::unordered_map ginac_pyomo_map; + PyomoExprTypesSimp expr_types; + bool symbolic_solver_labels = false; + + GinacInterface() = default; + GinacInterface(bool _symbolic_solver_labels) : symbolic_solver_labels(_symbolic_solver_labels) {} + ~GinacInterface() = default; + + ex to_ginac(py::handle expr); + py::object from_ginac(ex &ginac_expr); +}; diff --git a/pyomo/contrib/simplification/simplify.py b/pyomo/contrib/simplification/simplify.py new file mode 100644 index 00000000000..3e00d5729f3 --- /dev/null +++ b/pyomo/contrib/simplification/simplify.py @@ -0,0 +1,64 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2022 +# 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. +# ___________________________________________________________________________ + +from pyomo.core.expr.sympy_tools import sympy2pyomo_expression, sympyify_expression +from pyomo.core.expr.numeric_expr import NumericExpression +from pyomo.core.expr.numvalue import is_fixed, value +from pyomo.core.expr import native_numeric_types +import logging +import warnings + +try: + from pyomo.contrib.simplification.ginac_interface import GinacInterface + + ginac_available = True +except: + GinacInterface = None + ginac_available = False + + +logger = logging.getLogger(__name__) + + +def simplify_with_sympy(expr: NumericExpression): + if type(expr) in native_numeric_types: + return expr + om, se = sympyify_expression(expr) + se = se.simplify() + new_expr = sympy2pyomo_expression(se, om) + if is_fixed(new_expr): + new_expr = value(new_expr) + return new_expr + + +def simplify_with_ginac(expr: NumericExpression, ginac_interface): + gi = ginac_interface + ginac_expr = gi.to_ginac(expr) + ginac_expr = ginac_expr.normal() + new_expr = gi.from_ginac(ginac_expr) + return new_expr + + +class Simplifier(object): + def __init__(self, suppress_no_ginac_warnings: bool = False) -> None: + if ginac_available: + self.gi = GinacInterface(False) + self.suppress_no_ginac_warnings = suppress_no_ginac_warnings + + def simplify(self, expr: NumericExpression): + if ginac_available: + return simplify_with_ginac(expr, self.gi) + else: + if not self.suppress_no_ginac_warnings: + msg = f"GiNaC does not seem to be available. Using SymPy. Note that the GiNac interface is significantly faster." + logger.warning(msg) + warnings.warn(msg) + return simplify_with_sympy(expr) diff --git a/pyomo/contrib/simplification/tests/__init__.py b/pyomo/contrib/simplification/tests/__init__.py new file mode 100644 index 00000000000..d93cfd77b3c --- /dev/null +++ b/pyomo/contrib/simplification/tests/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2022 +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/simplification/tests/test_simplification.py b/pyomo/contrib/simplification/tests/test_simplification.py new file mode 100644 index 00000000000..95402f98318 --- /dev/null +++ b/pyomo/contrib/simplification/tests/test_simplification.py @@ -0,0 +1,128 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2022 +# 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. +# ___________________________________________________________________________ + +from pyomo.common.unittest import TestCase +from pyomo.common import unittest +from pyomo.contrib.simplification import Simplifier +from pyomo.contrib.simplification.simplify import ginac_available +from pyomo.core.expr.compare import assertExpressionsEqual, compare_expressions +import pyomo.environ as pe +from pyomo.core.expr.calculus.diff_with_pyomo import reverse_sd +from pyomo.common.dependencies import attempt_import + + +sympy, sympy_available = attempt_import('sympy') + + +class SimplificationMixin: + def compare_against_possible_results(self, got, expected_list): + success = False + for exp in expected_list: + if compare_expressions(got, exp): + success = True + break + self.assertTrue(success) + + def test_simplify(self): + m = pe.ConcreteModel() + x = m.x = pe.Var(bounds=(0, None)) + e = x * pe.log(x) + der1 = reverse_sd(e)[x] + der2 = reverse_sd(der1)[x] + simp = Simplifier() + der2_simp = simp.simplify(der2) + expected = x**-1.0 + assertExpressionsEqual(self, expected, der2_simp) + + def test_mul(self): + m = pe.ConcreteModel() + x = m.x = pe.Var() + e = 2 * x + simp = Simplifier() + e2 = simp.simplify(e) + expected = 2.0 * x + assertExpressionsEqual(self, expected, e2) + + def test_sum(self): + m = pe.ConcreteModel() + x = m.x = pe.Var() + e = 2 + x + simp = Simplifier() + e2 = simp.simplify(e) + self.compare_against_possible_results(e2, [2.0 + x, x + 2.0]) + + def test_neg(self): + m = pe.ConcreteModel() + x = m.x = pe.Var() + e = -pe.log(x) + simp = Simplifier() + e2 = simp.simplify(e) + self.compare_against_possible_results( + e2, [(-1.0) * pe.log(x), pe.log(x) * (-1.0), -pe.log(x)] + ) + + def test_pow(self): + m = pe.ConcreteModel() + x = m.x = pe.Var() + e = x**2.0 + simp = Simplifier() + e2 = simp.simplify(e) + assertExpressionsEqual(self, e, e2) + + def test_div(self): + m = pe.ConcreteModel() + x = m.x = pe.Var() + y = m.y = pe.Var() + e = x / y + y / x - x / y + simp = Simplifier() + e2 = simp.simplify(e) + print(e2) + self.compare_against_possible_results( + e2, [y / x, y * (1.0 / x), y * x**-1.0, x**-1.0 * y] + ) + + def test_unary(self): + m = pe.ConcreteModel() + x = m.x = pe.Var() + func_list = [pe.log, pe.sin, pe.cos, pe.tan, pe.asin, pe.acos, pe.atan] + for func in func_list: + e = func(x) + simp = Simplifier() + e2 = simp.simplify(e) + assertExpressionsEqual(self, e, e2) + + +@unittest.skipIf((not sympy_available) or (ginac_available), 'sympy is not available') +class TestSimplificationSympy(TestCase, SimplificationMixin): + pass + + +@unittest.skipIf(not ginac_available, 'GiNaC is not available') +@unittest.pytest.mark.simplification +class TestSimplificationGiNaC(TestCase, SimplificationMixin): + def test_param(self): + m = pe.ConcreteModel() + x = m.x = pe.Var() + p = m.p = pe.Param(mutable=True) + e1 = p * x**2 + p * x + p * x**2 + simp = Simplifier() + e2 = simp.simplify(e1) + self.compare_against_possible_results( + e2, + [ + p * x**2.0 * 2.0 + p * x, + p * x + p * x**2.0 * 2.0, + 2.0 * p * x**2.0 + p * x, + p * x + 2.0 * p * x**2.0, + x**2.0 * p * 2.0 + p * x, + p * x + x**2.0 * p * 2.0, + ], + ) diff --git a/pyomo/core/expr/compare.py b/pyomo/core/expr/compare.py index 790bc30aaee..44d9c4205d7 100644 --- a/pyomo/core/expr/compare.py +++ b/pyomo/core/expr/compare.py @@ -196,7 +196,7 @@ def compare_expressions(expr1, expr2, include_named_exprs=True): ) try: res = pn1 == pn2 - except PyomoException: + except (PyomoException, AttributeError): res = False return res diff --git a/setup.cfg b/setup.cfg index b606138f38c..855717490b3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,3 +22,4 @@ markers = lp: marks lp tests gams: marks gams tests bar: marks bar tests + simplification: tests for expression simplification that have expensive (to install) dependencies diff --git a/setup.py b/setup.py index 70c1626a650..90a79970e80 100644 --- a/setup.py +++ b/setup.py @@ -276,6 +276,7 @@ def __ne__(self, other): #'pathos', # requested for #963, but PR currently closed 'pint', # units 'plotly', # incidence_analysis + 'pybnb', # coramin 'python-louvain', # community_detection 'pyyaml', # core # qtconsole also requires a supported Qt version (PyQt5 or PySide6).