Repository: Pyomo/pyomo

Issue:
Feasibility pump still fails when using appsi_highs in MindtPy
## Summary

<!-- Explain, in a clear and concise way, the command you ran and the result you were trying to achieve.
Example: "I ran `pyomo solve` to optimize a model and ..." -->

MindtPy will support `appsi_highs` solver after #2887 gets merged. However, feasibility pump tests still fail after #2903 .
It's a little bit weird since MindtPy works well with `appsi_cplex` and `appsi_gurobi`. Therefore, I am wondering if there is anything special or different with `appsi_highs`?

### Steps to reproduce the issue

<!-- Please include the command run and/or provide an example script that demonstrates the problem. -->

Use MindtPy on the `mindtpy-rewrite` branch.

```
# example.py
from pyomo.environ import *
from pyomo.contrib.mindtpy.tests.online_doc_example import OnlineDocExample

results = SolverFactory('mindtpy').solve(
                OnlineDocExample(),
                strategy='FP',
                mip_solver='appsi_cplex',
                nlp_solver='ipopt',
                absolute_bound_tolerance=1e-5,
                tee=True
            )
print(results)


results = SolverFactory('mindtpy').solve(
                OnlineDocExample(),
                strategy='FP',
                mip_solver='appsi_highs',
                nlp_solver='ipopt',
                absolute_bound_tolerance=1e-5,
                tee=True
            )
print(results)

```

### Error Message

<!-- If Pyomo reported an error, provide the error message. PLEASE INCLUDE THE ENTIRE STACK TRACE. -->

<!-- If it did not report an error but the output appears incorrect, provide the incorrect output. If there was no error message and no output but the result is incorrect, describe how it does not match what you expect. -->

Output when using MindtPy with `appsi_cplex`

```
-----------------------------------------------------------------------------------------------
               Mixed-Integer Nonlinear Decomposition Toolbox in Pyomo (MindtPy)                
-----------------------------------------------------------------------------------------------
For more information, please visit 
https://pyomo.readthedocs.io/en/stable/contributed_packages/mindtpy.html
If you use this software, please cite the following:
Bernal, David E., et al. Mixed-integer nonlinear decomposition toolbox for Pyomo (MindtPy).
Computer Aided Chemical Engineering. Vol. 44. Elsevier, 2018. 895-900.

Original model has 2 constraints (2 nonlinear) and 0 disjunctions, with 2 variables, of which 1 are binary, 0 are integer, and 1 are continuous.
FP is the initial strategy being used.

 ===============================================================================================
 Iteration | Subproblem Type | Objective Value | Primal Bound |   Dual Bound |   Gap   | Time(s)

Moving objective to constraint set.
         -       Relaxed NLP                 1            inf              1      nan%      0.08
         1            FP-MIP          0.281353            inf              1      nan%      0.12
         1            FP-NLP              0.01            inf              1      nan%      0.15
         2            FP-MIP               0.9            inf              1      nan%      0.16
         2            FP-NLP       1.49165e-09            inf              1      nan%      0.20
*        1         Fixed NLP           2.43845        2.43845              1    58.99%      0.23
FP-MIP infeasible
 Primal integral          :    0.0000 
 Dual integral            :    0.0000 
 Primal-dual gap integral :    0.0000 
.
----------------------------------------------------------------------
```


Output of MindtPy with `appsi_highs`

```
-----------------------------------------------------------------------------------------------
               Mixed-Integer Nonlinear Decomposition Toolbox in Pyomo (MindtPy)                
-----------------------------------------------------------------------------------------------
For more information, please visit 
https://pyomo.readthedocs.io/en/stable/contributed_packages/mindtpy.html
If you use this software, please cite the following:
Bernal, David E., et al. Mixed-integer nonlinear decomposition toolbox for Pyomo (MindtPy).
Computer Aided Chemical Engineering. Vol. 44. Elsevier, 2018. 895-900.

Original model has 2 constraints (2 nonlinear) and 0 disjunctions, with 2 variables, of which 1 are binary, 0 are integer, and 1 are continuous.
FP is the initial strategy being used.

 ===============================================================================================
 Iteration | Subproblem Type | Objective Value | Primal Bound |   Dual Bound |   Gap   | Time(s)

Moving objective to constraint set.
         -       Relaxed NLP                 1            inf              1      nan%      0.07
Running HiGHS 1.5.3 [date: 2023-08-11, git hash: c3ca042f7]
Copyright (c) 2023 HiGHS under MIT licence terms
         1            FP-MIP          0.281353            inf              1      nan%      0.08
         1            FP-NLP              0.01            inf              1      nan%      0.12
         2            FP-MIP                 0            inf              1      nan%      0.12
         2            FP-NLP       1.33798e-16            inf              1      nan%      0.17
E
======================================================================
ERROR: test_FP (__main__.TestMindtPy)
Test the feasibility pump algorithm.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/zedongpeng/Github/pyomo/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py", line 62, in test_FP
    results = opt.solve(
  File "/Users/zedongpeng/Github/pyomo/pyomo/contrib/mindtpy/MindtPy.py", line 113, in solve
    return SolverFactory(_supported_algorithms[config.strategy][0]).solve(
  File "/Users/zedongpeng/Github/pyomo/pyomo/contrib/mindtpy/algorithm_base_class.py", line 2713, in solve
    self.MindtPy_initialization()
  File "/Users/zedongpeng/Github/pyomo/pyomo/contrib/mindtpy/algorithm_base_class.py", line 788, in MindtPy_initialization
    self.fp_loop()
  File "/Users/zedongpeng/Github/pyomo/pyomo/contrib/mindtpy/algorithm_base_class.py", line 2462, in fp_loop
    self.handle_fp_subproblem_optimal(fp_nlp)
  File "/Users/zedongpeng/Github/pyomo/pyomo/contrib/mindtpy/algorithm_base_class.py", line 2343, in handle_fp_subproblem_optimal
    self.handle_subproblem_optimal(fixed_nlp)
  File "/Users/zedongpeng/Github/pyomo/pyomo/contrib/mindtpy/algorithm_base_class.py", line 1160, in handle_subproblem_optimal
    add_no_good_cuts(
  File "/Users/zedongpeng/Github/pyomo/pyomo/contrib/mindtpy/cut_generation.py", line 327, in add_no_good_cuts
    raise ValueError(
ValueError: Binary y = 0.10000000869092351 is not 0 or 1
```

The difference is the second iteration of the feasibility pump.
Output of `appsi_cplex`
```
         2            FP-MIP               0.9            inf              1      nan%      0.16
Result of solving FP-MIP
Problem: 
- Lower bound: 0.8999999913090765
  Upper bound: 0.8999999913090765
  Number of objectives: 1
  Number of constraints: 0
  Number of variables: 0
  Sense: 1
Solver: 
- Status: ok
  Termination condition: optimal
  Termination message: TerminationCondition.optimal
Solution: 
- number of solutions: 1
  number of solutions displayed: 1
- Gap: 0.0
  Status: optimal
  Objective: No values
  Variable:
    x1:
      Value: 1
    x2:
      Value: 1
    x5:
      Value: 1
    x6:
      Value: 0.8999999913090765
  Constraint: No values
```
Output of `appsi_highs`
```
         2            FP-MIP                 0            inf              1      nan%      0.12
Result of solving FP-MIP
Problem: 
- Lower bound: 0.0
  Upper bound: 0.0
  Number of objectives: 1
  Number of constraints: 0
  Number of variables: 0
  Sense: 1
Solver: 
- Status: ok
  Termination condition: optimal
  Termination message: TerminationCondition.optimal
Solution: 
- number of solutions: 1
  number of solutions displayed: 1
- Gap: 0.0
  Status: optimal
  Objective: No values
  Variable:
    MindtPy_utils.objective_value[1]:
      Value: 1
    x:
      Value: 1
    y:
      Value: 0.10000000869092351
  Constraint: No values
```

I set the `update_config` all to `True`.


### Information on your system

Pyomo version: 6.6.2.dev0
Python version: 3.10.12
Operating system: macOS
How Pyomo was installed (PyPI, conda, source): source
Solver (if applicable): appsi_cplex, appsi_highs, ipopt


### Additional information

<!-- If you have any additional information, please list it here. -->


<!-- We encourage you to try, as much as possible, to reduce your problem to the minimal example that still reproduces the issue. That would help us a lot in fixing it quickly and effectively!

If you want to ask a question (how to use Pyomo, what it can do, etc.), please refer to our [online documentation](https://github.com/Pyomo/pyomo#getting-help) on ways to get help. -->




Discussion:
I've created an issue in the HiGHS repo for this (https://github.com/ERGO-Code/HiGHS/issues/1386).

Resolving patch:
diff --git a/pyomo/contrib/appsi/solvers/highs.py b/pyomo/contrib/appsi/solvers/highs.py
--- a/pyomo/contrib/appsi/solvers/highs.py
+++ b/pyomo/contrib/appsi/solvers/highs.py
@@ -176,11 +176,19 @@ def available(self):
             return self.Availability.NotFound
 
     def version(self):
-        version = (
-            highspy.HIGHS_VERSION_MAJOR,
-            highspy.HIGHS_VERSION_MINOR,
-            highspy.HIGHS_VERSION_PATCH,
-        )
+        try:
+            version = (
+                highspy.HIGHS_VERSION_MAJOR,
+                highspy.HIGHS_VERSION_MINOR,
+                highspy.HIGHS_VERSION_PATCH,
+            )
+        except AttributeError:
+            # Older versions of Highs do not have the above attributes
+            # and the solver version can only be obtained by making
+            # an instance of the solver class.
+            tmp = highspy.Highs()
+            version = (tmp.versionMajor(), tmp.versionMinor(), tmp.versionPatch())
+
         return version
 
     @property
diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py
--- a/pyomo/contrib/mindtpy/algorithm_base_class.py
+++ b/pyomo/contrib/mindtpy/algorithm_base_class.py
@@ -146,7 +146,9 @@ def __init__(self, **kwds):
         # Store the OA cuts generated in the mip_start_process.
         self.mip_start_lazy_oa_cuts = []
         # Whether to load solutions in solve() function
-        self.load_solutions = True
+        self.mip_load_solutions = True
+        self.nlp_load_solutions = True
+        self.regularization_mip_load_solutions = True
 
     # Support use as a context manager under current solver API
     def __enter__(self):
@@ -296,7 +298,7 @@ def model_is_valid(self):
                 results = self.mip_opt.solve(
                     self.original_model,
                     tee=config.mip_solver_tee,
-                    load_solutions=self.load_solutions,
+                    load_solutions=self.mip_load_solutions,
                     **config.mip_solver_args,
                 )
                 if len(results.solution) > 0:
@@ -838,7 +840,7 @@ def init_rNLP(self, add_oa_cuts=True):
             results = self.nlp_opt.solve(
                 self.rnlp,
                 tee=config.nlp_solver_tee,
-                load_solutions=self.load_solutions,
+                load_solutions=self.nlp_load_solutions,
                 **nlp_args,
             )
             if len(results.solution) > 0:
@@ -860,7 +862,7 @@ def init_rNLP(self, add_oa_cuts=True):
             results = self.nlp_opt.solve(
                 self.rnlp,
                 tee=config.nlp_solver_tee,
-                load_solutions=self.load_solutions,
+                load_solutions=self.nlp_load_solutions,
                 **nlp_args,
             )
             if len(results.solution) > 0:
@@ -991,7 +993,10 @@ def init_max_binaries(self):
         mip_args = dict(config.mip_solver_args)
         update_solver_timelimit(self.mip_opt, config.mip_solver, self.timing, config)
         results = self.mip_opt.solve(
-            m, tee=config.mip_solver_tee, load_solutions=self.load_solutions, **mip_args
+            m,
+            tee=config.mip_solver_tee,
+            load_solutions=self.mip_load_solutions,
+            **mip_args,
         )
         if len(results.solution) > 0:
             m.solutions.load_from(results)
@@ -1111,7 +1116,7 @@ def solve_subproblem(self):
                 results = self.nlp_opt.solve(
                     self.fixed_nlp,
                     tee=config.nlp_solver_tee,
-                    load_solutions=self.load_solutions,
+                    load_solutions=self.nlp_load_solutions,
                     **nlp_args,
                 )
                 if len(results.solution) > 0:
@@ -1379,12 +1384,20 @@ def solve_feasibility_subproblem(self):
         update_solver_timelimit(
             self.feasibility_nlp_opt, config.nlp_solver, self.timing, config
         )
-        TransformationFactory('contrib.deactivate_trivial_constraints').apply_to(
-            feas_subproblem,
-            tmp=True,
-            ignore_infeasible=False,
-            tolerance=config.constraint_tolerance,
-        )
+        try:
+            TransformationFactory('contrib.deactivate_trivial_constraints').apply_to(
+                self.fixed_nlp,
+                tmp=True,
+                ignore_infeasible=False,
+                tolerance=config.constraint_tolerance,
+            )
+        except InfeasibleConstraintException as e:
+            config.logger.error(
+                str(e) + '\nInfeasibility detected in deactivate_trivial_constraints.'
+            )
+            results = SolverResults()
+            results.solver.termination_condition = tc.infeasible
+            return self.fixed_nlp, results
         with SuppressInfeasibleWarning():
             try:
                 with time_code(self.timing, 'feasibility subproblem'):
@@ -1570,7 +1583,7 @@ def fix_dual_bound(self, last_iter_cuts):
             main_mip_results = self.mip_opt.solve(
                 self.mip,
                 tee=config.mip_solver_tee,
-                load_solutions=self.load_solutions,
+                load_solutions=self.mip_load_solutions,
                 **mip_args,
             )
             if len(main_mip_results.solution) > 0:
@@ -1658,7 +1671,7 @@ def solve_main(self):
             main_mip_results = self.mip_opt.solve(
                 self.mip,
                 tee=config.mip_solver_tee,
-                load_solutions=self.load_solutions,
+                load_solutions=self.mip_load_solutions,
                 **mip_args,
             )
             # update_attributes should be before load_from(main_mip_results), since load_from(main_mip_results) may fail.
@@ -1719,7 +1732,7 @@ def solve_fp_main(self):
         main_mip_results = self.mip_opt.solve(
             self.mip,
             tee=config.mip_solver_tee,
-            load_solutions=self.load_solutions,
+            load_solutions=self.mip_load_solutions,
             **mip_args,
         )
         # update_attributes should be before load_from(main_mip_results), since load_from(main_mip_results) may fail.
@@ -1762,7 +1775,7 @@ def solve_regularization_main(self):
         main_mip_results = self.regularization_mip_opt.solve(
             self.mip,
             tee=config.mip_solver_tee,
-            load_solutions=self.load_solutions,
+            load_solutions=self.regularization_mip_load_solutions,
             **dict(config.mip_solver_args),
         )
         if len(main_mip_results.solution) > 0:
@@ -1978,7 +1991,7 @@ def handle_main_unbounded(self, main_mip):
             main_mip_results = self.mip_opt.solve(
                 main_mip,
                 tee=config.mip_solver_tee,
-                load_solutions=self.load_solutions,
+                load_solutions=self.mip_load_solutions,
                 **config.mip_solver_args,
             )
             if len(main_mip_results.solution) > 0:
@@ -2261,6 +2274,11 @@ def check_subsolver_validity(self):
             raise ValueError(self.config.mip_solver + ' is not available.')
         if not self.mip_opt.license_is_valid():
             raise ValueError(self.config.mip_solver + ' is not licensed.')
+        if self.config.mip_solver == "appsi_highs":
+            if self.mip_opt.version() < (1, 7, 0):
+                raise ValueError(
+                    "MindtPy requires the use of HIGHS version 1.7.0 or higher for full compatibility."
+                )
         if not self.nlp_opt.available():
             raise ValueError(self.config.nlp_solver + ' is not available.')
         if not self.nlp_opt.license_is_valid():
@@ -2308,15 +2326,15 @@ def check_config(self):
                 config.mip_solver = 'cplex_persistent'
 
         # related to https://github.com/Pyomo/pyomo/issues/2363
+        if 'appsi' in config.mip_solver:
+            self.mip_load_solutions = False
+        if 'appsi' in config.nlp_solver:
+            self.nlp_load_solutions = False
         if (
-            'appsi' in config.mip_solver
-            or 'appsi' in config.nlp_solver
-            or (
-                config.mip_regularization_solver is not None
-                and 'appsi' in config.mip_regularization_solver
-            )
+            config.mip_regularization_solver is not None
+            and 'appsi' in config.mip_regularization_solver
         ):
-            self.load_solutions = False
+            self.regularization_mip_load_solutions = False
 
     ################################################################################################################################
     # Feasibility Pump
@@ -2384,7 +2402,7 @@ def solve_fp_subproblem(self):
                 results = self.nlp_opt.solve(
                     fp_nlp,
                     tee=config.nlp_solver_tee,
-                    load_solutions=self.load_solutions,
+                    load_solutions=self.nlp_load_solutions,
                     **nlp_args,
                 )
                 if len(results.solution) > 0:
diff --git a/pyomo/contrib/mindtpy/config_options.py b/pyomo/contrib/mindtpy/config_options.py
--- a/pyomo/contrib/mindtpy/config_options.py
+++ b/pyomo/contrib/mindtpy/config_options.py
@@ -549,7 +549,7 @@ def _add_subsolver_configs(CONFIG):
                     'cplex_persistent',
                     'appsi_cplex',
                     'appsi_gurobi',
-                    # 'appsi_highs', TODO: feasibility pump now fails with appsi_highs #2951
+                    'appsi_highs',
                 ]
             ),
             description='MIP subsolver name',
@@ -631,7 +631,7 @@ def _add_subsolver_configs(CONFIG):
                     'cplex_persistent',
                     'appsi_cplex',
                     'appsi_gurobi',
-                    # 'appsi_highs',
+                    'appsi_highs',
                 ]
             ),
             description='MIP subsolver for regularization problem',


Test patch:
diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml
--- a/.github/workflows/test_pr_and_main.yml
+++ b/.github/workflows/test_pr_and_main.yml
@@ -612,7 +612,8 @@ jobs:
       if: ${{ ! matrix.slim }}
       shell: bash
       run: |
-        $PYTHON_EXE -m pip install --cache-dir cache/pip highspy \
+        echo "NOTE: temporarily pinning to highspy pre-release for testing"
+        $PYTHON_EXE -m pip install --cache-dir cache/pip "highspy>=1.7.1.dev1" \
             || echo "WARNING: highspy is not available"
 
     - name: Set up coverage tracking
diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy.py b/pyomo/contrib/mindtpy/tests/test_mindtpy.py
--- a/pyomo/contrib/mindtpy/tests/test_mindtpy.py
+++ b/pyomo/contrib/mindtpy/tests/test_mindtpy.py
@@ -56,7 +56,12 @@
 QCP_model._generate_model()
 extreme_model_list = [LP_model.model, QCP_model.model]
 
-required_solvers = ('ipopt', 'glpk')
+if SolverFactory('appsi_highs').available(exception_flag=False) and SolverFactory(
+    'appsi_highs'
+).version() >= (1, 7, 0):
+    required_solvers = ('ipopt', 'appsi_highs')
+else:
+    required_solvers = ('ipopt', 'glpk')
 if all(SolverFactory(s).available(exception_flag=False) for s in required_solvers):
     subsolvers_available = True
 else:
diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py
--- a/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py
+++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py
@@ -23,7 +23,13 @@
 from pyomo.environ import SolverFactory, value
 from pyomo.opt import TerminationCondition
 
-required_solvers = ('ipopt', 'glpk')
+if SolverFactory('appsi_highs').available(exception_flag=False) and SolverFactory(
+    'appsi_highs'
+).version() >= (1, 7, 0):
+    required_solvers = ('ipopt', 'appsi_highs')
+else:
+    required_solvers = ('ipopt', 'glpk')
+
 if all(SolverFactory(s).available(exception_flag=False) for s in required_solvers):
     subsolvers_available = True
 else:
diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py
--- a/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py
+++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py
@@ -28,8 +28,13 @@
 from pyomo.contrib.mindtpy.tests.feasibility_pump1 import FeasPump1
 from pyomo.contrib.mindtpy.tests.feasibility_pump2 import FeasPump2
 
-required_solvers = ('ipopt', 'glpk')
-# TODO: 'appsi_highs' will fail here.
+if SolverFactory('appsi_highs').available(exception_flag=False) and SolverFactory(
+    'appsi_highs'
+).version() >= (1, 7, 0):
+    required_solvers = ('ipopt', 'appsi_highs')
+else:
+    required_solvers = ('ipopt', 'glpk')
+
 if all(SolverFactory(s).available(exception_flag=False) for s in required_solvers):
     subsolvers_available = True
 else:
diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py
--- a/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py
+++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py
@@ -18,7 +18,14 @@
 from pyomo.contrib.mindtpy.tests.MINLP_simple import SimpleMINLP as SimpleMINLP
 
 model_list = [SimpleMINLP(grey_box=True)]
-required_solvers = ('cyipopt', 'glpk')
+
+if SolverFactory('appsi_highs').available(exception_flag=False) and SolverFactory(
+    'appsi_highs'
+).version() >= (1, 7, 0):
+    required_solvers = ('cyipopt', 'appsi_highs')
+else:
+    required_solvers = ('cyipopt', 'glpk')
+
 if all(SolverFactory(s).available(exception_flag=False) for s in required_solvers):
     subsolvers_available = True
 else: