diff --git a/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py b/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py index 2df8f9da390..0bdd09038c7 100644 --- a/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py +++ b/pyomo/contrib/appsi/solvers/tests/test_highs_persistent.py @@ -18,7 +18,6 @@ from pyomo.common.log import LoggingIntercept from pyomo.common.tee import capture_output from pyomo.contrib.appsi.solvers.highs import Highs -from pyomo.contrib.appsi.base import TerminationCondition opt = Highs() diff --git a/pyomo/contrib/solver/solvers/highs.py b/pyomo/contrib/solver/solvers/highs.py index 3facfc00d13..c554682ca9a 100644 --- a/pyomo/contrib/solver/solvers/highs.py +++ b/pyomo/contrib/solver/solvers/highs.py @@ -96,6 +96,121 @@ def update(self): self.highs.changeColCost(col_ndx, value(self.expr)) +class _MutableQuadraticCoefficient: + def __init__(self, expr, row_idx, col_idx): + self.expr = expr + self.row_idx = row_idx + self.col_idx = col_idx + + +class _MutableObjective: + def __init__(self, highs, constant, linear_coefs, quadratic_coefs): + self.highs = highs + self.constant = constant + self.linear_coefs = linear_coefs + self.quadratic_coefs = quadratic_coefs + self.last_quadratic_coef_values = [value(i.expr) for i in self.quadratic_coefs] + # Store the quadratic coefficients in dictionary format + self.quad_coef_dict = {} + self._initialize_quad_coef_dict() + # Flag to force first update of quadratic coefficients + self._first_update = True + + def _initialize_quad_coef_dict(self): + for coef in self.quadratic_coefs: + v1_ndx = coef.row_idx + v2_ndx = coef.col_idx + # Ensure we're storing the lower triangular part + row = max(v1_ndx, v2_ndx) + col = min(v1_ndx, v2_ndx) + + coef_val = value(coef.expr) + # Adjust for diagonal elements + if v1_ndx == v2_ndx: + coef_val *= 2.0 + + self.quad_coef_dict[(row, col)] = coef_val + + def update(self): + """ + Update the quadratic objective expression. + """ + needs_quadratic_update = self._first_update + + self.constant.update() + for coef in self.linear_coefs: + coef.update() + + for ndx, coef in enumerate(self.quadratic_coefs): + current_val = value(coef.expr) + if current_val != self.last_quadratic_coef_values[ndx]: + needs_quadratic_update = True + + v1_ndx = coef.row_idx + v2_ndx = coef.col_idx + row = max(v1_ndx, v2_ndx) + col = min(v1_ndx, v2_ndx) + + # Adjust the diagonal to match Highs' expected format + if v1_ndx == v2_ndx: + current_val *= 2.0 + + self.quad_coef_dict[(row, col)] = current_val + + self.last_quadratic_coef_values[ndx] = current_val + + # If anything changed, rebuild and pass the Hessian + if needs_quadratic_update: + self._build_and_pass_hessian() + self._first_update = False + + def _build_and_pass_hessian(self): + """Build and pass the Hessian to HiGHS in CSC format""" + if not self.quad_coef_dict: + return + + dim = self.highs.getNumCol() + + # Build CSC format for the lower triangular part + q_value = [] + q_index = [] + q_start = [0] * dim + + sorted_entries = sorted( + self.quad_coef_dict.items(), key=lambda x: (x[0][1], x[0][0]) + ) + + last_col = -1 + for (row, col), val in sorted_entries: + while col > last_col: + last_col += 1 + if last_col < dim: + q_start[last_col] = len(q_value) + + # Add the entry + q_index.append(row) + q_value.append(val) + + while last_col < dim - 1: + last_col += 1 + q_start[last_col] = len(q_value) + + nnz = len(q_value) + status = self.highs.passHessian( + dim, + nnz, + highspy.HessianFormat.kTriangular, + np.array(q_start, dtype=np.int32), + np.array(q_index, dtype=np.int32), + np.array(q_value, dtype=np.double), + ) + + if status != highspy.HighsStatus.kOk: + logger.warning( + f"HiGHS returned non-OK status when passing Hessian: {status}" + ) + + class _MutableObjectiveOffset: def __init__(self, expr, highs): self.expr = expr @@ -141,7 +256,6 @@ def __init__(self, **kwds): self._solver_con_to_pyomo_con_map = {} self._mutable_helpers = {} self._mutable_bounds = {} - self._objective_helpers = [] self._last_results_object: Optional[Results] = None self._sol = None @@ -472,13 +586,14 @@ def update_parameters(self): self._sol = None if self._last_results_object is not None: self._last_results_object.solution_loader.invalidate() + for con, helpers in self._mutable_helpers.items(): for helper in helpers: helper.update() for k, (v, helper) in self._mutable_bounds.items(): helper.update() - for helper in self._objective_helpers: - helper.update() + + self._mutable_objective.update() def _set_objective(self, obj): self._sol = None @@ -487,10 +602,14 @@ def _set_objective(self, obj): n = len(self._pyomo_var_to_solver_var_map) indices = np.arange(n) costs = np.zeros(n, dtype=np.double) - self._objective_helpers = [] + + # Initialize empty lists for all coefficient types + mutable_linear_coefficients = [] + mutable_quadratic_coefficients = [] + if obj is None: sense = highspy.ObjSense.kMinimize - self._solver_model.changeObjectiveOffset(0) + mutable_constant = _MutableObjectiveOffset(expr=0, highs=self._solver_model) else: if obj.sense == minimize: sense = highspy.ObjSense.kMinimize @@ -500,9 +619,9 @@ def _set_objective(self, obj): raise ValueError(f'Objective sense is not recognized: {obj.sense}') repn = generate_standard_repn( - obj.expr, quadratic=False, compute_values=False + obj.expr, quadratic=True, compute_values=False ) - if repn.nonlinear_expr is not None: + if repn.nonlinear_expr is not None or repn.polynomial_degree() > 2: raise IncompatibleModelError( f'Highs interface does not support expressions of degree {repn.polynomial_degree()}' ) @@ -518,17 +637,36 @@ def _set_objective(self, obj): expr=coef, highs=self._solver_model, ) - self._objective_helpers.append(mutable_objective_coef) + mutable_linear_coefficients.append(mutable_objective_coef) - self._solver_model.changeObjectiveOffset(value(repn.constant)) - if not is_constant(repn.constant): - mutable_objective_offset = _MutableObjectiveOffset( - expr=repn.constant, highs=self._solver_model - ) - self._objective_helpers.append(mutable_objective_offset) + mutable_constant = _MutableObjectiveOffset( + expr=repn.constant, highs=self._solver_model + ) + + if repn.quadratic_vars and len(repn.quadratic_vars) > 0: + for ndx, (v1, v2) in enumerate(repn.quadratic_vars): + v1_id = id(v1) + v2_id = id(v2) + v1_ndx = self._pyomo_var_to_solver_var_map[v1_id] + v2_ndx = self._pyomo_var_to_solver_var_map[v2_id] + + coef = repn.quadratic_coefs[ndx] + + mutable_quadratic_coefficients.append( + _MutableQuadraticCoefficient( + expr=coef, row_idx=v1_ndx, col_idx=v2_ndx + ) + ) self._solver_model.changeObjectiveSense(sense) self._solver_model.changeColsCost(n, indices, costs) + self._mutable_objective = _MutableObjective( + self._solver_model, + mutable_constant, + mutable_linear_coefficients, + mutable_quadratic_coefficients, + ) + self._mutable_objective.update() def _postsolve(self): config = self._active_config diff --git a/pyomo/contrib/solver/tests/solvers/test_highs.py b/pyomo/contrib/solver/tests/solvers/test_highs.py new file mode 100644 index 00000000000..be1a2cf056d --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_highs.py @@ -0,0 +1,158 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pyomo.common.unittest as unittest +import pyomo.environ as pe + +from pyomo.contrib.solver.solvers.highs import Highs + +opt = Highs() +if not opt.available(): + raise unittest.SkipTest + + +class TestBugs(unittest.TestCase): + def test_mutable_params_with_remove_cons(self): + m = pe.ConcreteModel() + m.x = pe.Var(bounds=(-10, 10)) + m.y = pe.Var() + + m.p1 = pe.Param(mutable=True) + m.p2 = pe.Param(mutable=True) + + m.obj = pe.Objective(expr=m.y) + m.c1 = pe.Constraint(expr=m.y >= m.x + m.p1) + m.c2 = pe.Constraint(expr=m.y >= -m.x + m.p2) + + m.p1.value = 1 + m.p2.value = 1 + + opt = Highs() + res = opt.solve(m) + self.assertAlmostEqual(res.objective_bound, 1) + + del m.c1 + m.p2.value = 2 + res = opt.solve(m) + self.assertAlmostEqual(res.objective_bound, -8) + + def test_mutable_params_with_remove_vars(self): + m = pe.ConcreteModel() + m.x = pe.Var() + m.y = pe.Var() + + m.p1 = pe.Param(mutable=True) + m.p2 = pe.Param(mutable=True) + + m.y.setlb(m.p1) + m.y.setub(m.p2) + + 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.p1.value = -10 + m.p2.value = 10 + + opt = Highs() + res = opt.solve(m) + self.assertAlmostEqual(res.objective_bound, 1) + + del m.c1 + del m.c2 + m.p1.value = -9 + m.p2.value = 9 + res = opt.solve(m) + self.assertAlmostEqual(res.objective_bound, -9) + + def test_fix_and_unfix(self): + # Tests issue https://github.com/Pyomo/pyomo/issues/3127 + + m = pe.ConcreteModel() + m.x = pe.Var(domain=pe.Binary) + m.y = pe.Var(domain=pe.Binary) + m.fx = pe.Var(domain=pe.NonNegativeReals) + m.fy = pe.Var(domain=pe.NonNegativeReals) + m.c1 = pe.Constraint(expr=m.fx <= m.x) + m.c2 = pe.Constraint(expr=m.fy <= m.y) + m.c3 = pe.Constraint(expr=m.x + m.y <= 1) + + m.obj = pe.Objective(expr=m.fx * 0.5 + m.fy * 0.4, sense=pe.maximize) + + opt = Highs() + + # solution 1 has m.x == 1 and m.y == 0 + r = opt.solve(m) + self.assertAlmostEqual(m.fx.value, 1, places=5) + self.assertAlmostEqual(m.fy.value, 0, places=5) + self.assertAlmostEqual(r.objective_bound, 0.5, places=5) + + # solution 2 has m.x == 0 and m.y == 1 + m.y.fix(1) + r = opt.solve(m) + self.assertAlmostEqual(m.fx.value, 0, places=5) + self.assertAlmostEqual(m.fy.value, 1, places=5) + self.assertAlmostEqual(r.objective_bound, 0.4, places=5) + + # solution 3 should be equal solution 1 + m.y.unfix() + m.x.fix(1) + r = opt.solve(m) + self.assertAlmostEqual(m.fx.value, 1, places=5) + self.assertAlmostEqual(m.fy.value, 0, places=5) + self.assertAlmostEqual(r.objective_bound, 0.5, places=5) + + def test_qp1(self): + # test issue #3381 + m = pe.ConcreteModel() + + m.x1 = pe.Var(name='x1', domain=pe.Reals) + m.x2 = pe.Var(name='x2', domain=pe.Reals) + + # Quadratic Objective function + m.obj = pe.Objective(expr=m.x1 * m.x1 + m.x2 * m.x2, sense=pe.minimize) + + m.con1 = pe.Constraint(expr=m.x1 >= 1) + m.con2 = pe.Constraint(expr=m.x2 >= 1) + + results = opt.solve(m) + self.assertAlmostEqual(m.x1.value, 1, places=5) + self.assertAlmostEqual(m.x2.value, 1, places=5) + self.assertEqual(results.objective_bound, 2) + + def test_qp2(self): + # test issue #3381 + m = pe.ConcreteModel() + + m.x1 = pe.Var(name='x1', domain=pe.Reals) + m.x2 = pe.Var(name='x2', domain=pe.Reals) + + m.p = pe.Param(initialize=1, mutable=True) + + m.obj = pe.Objective( + expr=m.p * m.x1 * m.x1 + m.x2 * m.x2 - m.x1 * m.x2, sense=pe.minimize + ) + + m.con1 = pe.Constraint(expr=m.x1 >= 1) + m.con2 = pe.Constraint(expr=m.x2 >= 1) + + opt.set_instance(m) + results = opt.solve(m) + self.assertAlmostEqual(m.x1.value, 1, places=5) + self.assertAlmostEqual(m.x2.value, 1, places=5) + self.assertEqual(results.objective_bound, 1) + + m.p.value = 2.0 + opt.update_parameters() + results = opt.solve(m) + self.assertAlmostEqual(m.x1.value, 1, places=5) + self.assertAlmostEqual(m.x2.value, 1, places=5) + self.assertEqual(results.objective_bound, 2)