Repository: scipy/scipy

Issue:
BUG: Linprog reports failure despite success convergence, given a problem involving semi-continuous variables
### Describe your issue.

Given the following problem involving a semi-continuous variable, `linprog(method='highs')` reports a failure despite successful convergence to a solution.

The problem is the following:

```
maximize x
s.t
    x <= 0.5
    x is semi-continuous, bounds=[1;5]
```

Linprog reports the correct solution: `x=[0.]` and `slack=[0.5]`, but the `success` flag is `False` and there is an error message.

### Reproducing Code Example

```python
import numpy as np
from scipy.optimize import linprog

c = np.array([-1.])
A_ub = np.array([[1.]])
b_ub = np.array([0.5])
A_eq = np.zeros((0, 1), dtype=float)
b_eq = np.array([], dtype=float)
bounds = np.array([[1., 5.]])
integrality = np.array([2])

ans = linprog(
    c,
    A_ub,
    b_ub,
    A_eq,
    b_eq,
    bounds,
    integrality=integrality,
    method='highs'
)
print(f'ans.success: {ans.success}')
print(f'ans.x: {ans.x}')
print(f'ans.slack: {ans.slack}')
print(f'ans.message: {ans.message}')
```


### Error message

```shell
ans.success: False
ans.x: [0.]
ans.slack: [0.5]
ans.message: The solution does not satisfy the constraints within the required tolerance of 3.16E-04, yet no errors were raised and there is no certificate of infeasibility or unboundedness. Check whether the slack and constraint residuals are acceptable; if not, consider enabling presolve, adjusting the tolerance option(s), and/or using a different method. Please consider submitting a bug report.
```


### SciPy/NumPy/Python version and system information

```shell
1.10.1 1.24.2 sys.version_info(major=3, minor=10, micro=6, releaselevel='final', serial=0)
{
  "Compilers": {
    "c": {
      "name": "gcc",
      "linker": "ld.bfd",
      "version": "10.2.1",
      "commands": "cc"
    },
    "cython": {
      "name": "cython",
      "linker": "cython",
      "version": "0.29.33",
      "commands": "cython"
    },
    "c++": {
      "name": "gcc",
      "linker": "ld.bfd",
      "version": "10.2.1",
      "commands": "c++"
    },
    "fortran": {
      "name": "gcc",
      "linker": "ld.bfd",
      "version": "10.2.1",
      "commands": "gfortran"
    },
    "pythran": {
      "version": "0.12.1",
      "include directory": "/tmp/pip-build-env-xob7rtip/overlay/lib/python3.10/site-packages/pythran"
    }
  },
  "Machine Information": {
    "host": {
      "cpu": "x86_64",
      "family": "x86_64",
      "endian": "little",
      "system": "linux"
    },
    "build": {
      "cpu": "x86_64",
      "family": "x86_64",
      "endian": "little",
      "system": "linux"
    },
    "cross-compiled": false
  },
  "Build Dependencies": {
    "blas": {
      "name": "OpenBLAS",
      "found": true,
      "version": "0.3.18",
      "detection method": "cmake",
      "include directory": "unknown",
      "lib directory": "unknown",
      "openblas configuration": "unknown",
      "pc file directory": "unknown"
    },
    "lapack": {
      "name": "OpenBLAS",
      "found": true,
      "version": "0.3.18",
      "detection method": "cmake",
      "include directory": "unknown",
      "lib directory": "unknown",
      "openblas configuration": "unknown",
      "pc file directory": "unknown"
    }
  },
  "Python Information": {
    "path": "/opt/python/cp310-cp310/bin/python",
    "version": "3.10"
  }
}
```



Discussion:
I looked into the implementation of `linprog`. The bounds checking in `_check_results` doesn't make sense for semi-continuous variables:

https://github.com/scipy/scipy/blob/4a6f2d1e9a66a9447dd6a69a44be7170f2567b31/scipy/optimize/_linprog_util.py#L1491

Semi-continuous variables can always take value 0 (same for semi-integer variables), regardless of the bounds. At least that's what the documentation of `linprog` is saying for the `integrality` flag:
> 2 : Semi-continuous variable; decision variable must be within bounds or take value 0.
> 3 : Semi-integer variable; decision variable must be an integer within bounds or take value 0.

Thanks. Those checks were probably written before the semi-continuous bounds were added. We'll adjust that.

Resolving patch:
diff --git a/scipy/optimize/_linprog.py b/scipy/optimize/_linprog.py
--- a/scipy/optimize/_linprog.py
+++ b/scipy/optimize/_linprog.py
@@ -640,7 +640,8 @@ def linprog(c, A_ub=None, b_ub=None, A_eq=None, b_eq=None,
                              **solver_options)
         sol['status'], sol['message'] = (
             _check_result(sol['x'], sol['fun'], sol['status'], sol['slack'],
-                          sol['con'], lp.bounds, tol, sol['message']))
+                          sol['con'], lp.bounds, tol, sol['message'],
+                          integrality))
         sol['success'] = sol['status'] == 0
         return OptimizeResult(sol)
 
@@ -692,7 +693,8 @@ def linprog(c, A_ub=None, b_ub=None, A_eq=None, b_eq=None,
 
     x, fun, slack, con = _postsolve(x, postsolve_args, complete)
 
-    status, message = _check_result(x, fun, status, slack, con, lp_o.bounds, tol, message)
+    status, message = _check_result(x, fun, status, slack, con, lp_o.bounds,
+                                    tol, message, integrality)
 
     if disp:
         _display_summary(message, status, fun, iteration)
diff --git a/scipy/optimize/_linprog_util.py b/scipy/optimize/_linprog_util.py
--- a/scipy/optimize/_linprog_util.py
+++ b/scipy/optimize/_linprog_util.py
@@ -1416,7 +1416,8 @@ def _postsolve(x, postsolve_args, complete=False):
     return x, fun, slack, con
 
 
-def _check_result(x, fun, status, slack, con, bounds, tol, message):
+def _check_result(x, fun, status, slack, con, bounds, tol, message,
+                  integrality):
     """
     Check the validity of the provided solution.
 
@@ -1488,7 +1489,13 @@ def _check_result(x, fun, status, slack, con, bounds, tol, message):
     if contains_nans:
         is_feasible = False
     else:
-        invalid_bounds = (x < bounds[:, 0] - tol).any() or (x > bounds[:, 1] + tol).any()
+        if integrality is None:
+            integrality = 0
+        valid_bounds = (x >= bounds[:, 0] - tol) & (x <= bounds[:, 1] + tol)
+        # When integrality is 2 or 3, x must be within bounds OR take value 0
+        valid_bounds |= (integrality > 1) & np.isclose(x, 0, atol=tol)
+        invalid_bounds = not np.all(valid_bounds)
+
         invalid_slack = status != 3 and (slack < -tol).any()
         invalid_con = status != 3 and (np.abs(con) > tol).any()
         is_feasible = not (invalid_bounds or invalid_slack or invalid_con)


Test patch:
diff --git a/scipy/optimize/tests/test_linprog.py b/scipy/optimize/tests/test_linprog.py
--- a/scipy/optimize/tests/test_linprog.py
+++ b/scipy/optimize/tests/test_linprog.py
@@ -2365,6 +2365,21 @@ def test_mip_rel_gap_passdown(self):
         assert np.all(gap_diffs >= 0)
         assert not np.all(gap_diffs == 0)
 
+    def test_semi_continuous(self):
+        # See issue #18106. This tests whether the solution is being
+        # checked correctly (status is 0) when integrality > 1:
+        # values are allowed to be 0 even if 0 is out of bounds.
+
+        c = np.array([1., 1., -1, -1])
+        bounds = np.array([[0.5, 1.5], [0.5, 1.5], [0.5, 1.5], [0.5, 1.5]])
+        integrality = np.array([2, 3, 2, 3])
+
+        res = linprog(c, bounds=bounds,
+                      integrality=integrality, method='highs')
+
+        np.testing.assert_allclose(res.x, [0, 0, 1.5, 1])
+        assert res.status == 0
+
 
 ###########################
 # Autoscale-Specific Tests#