Skip to content

Commit b7aba5f

Browse files
feat: add sos reformulations into linopy to simplify adoption of new sos features (#549)
* The SOS constraint reformulation feature has been implemented successfully. Here's a summary: Implementation Summary New File: linopy/sos_reformulation.py Core reformulation functions: - validate_bounds_for_reformulation() - Validates that variables have finite bounds - compute_big_m_values() - Computes Big-M values from variable bounds - reformulate_sos1() - Reformulates SOS1 constraints using binary indicators and Big-M constraints - reformulate_sos2() - Reformulates SOS2 constraints using segment indicators and adjacency constraints - reformulate_all_sos() - Reformulates all SOS constraints in a model Modified: linopy/model.py - Added import for reformulate_all_sos - Added reformulate_sos_constraints() method to Model class - Added reformulate_sos: bool = False parameter to solve() method - Updated SOS constraint check to automatically reformulate when reformulate_sos=True and solver doesn't support SOS natively New Test File: test/test_sos_reformulation.py 36 comprehensive tests covering: - Bound validation (finite/infinite) - Big-M computation - SOS1 reformulation (basic, negative bounds, multi-dimensional) - SOS2 reformulation (basic, trivial cases, adjacency) - Integration with solve() and HiGHS - Equivalence with native Gurobi SOS support - Edge cases (zero bounds, multiple SOS, custom prefix) Usage Example m = linopy.Model() x = m.add_variables(lower=0, upper=1, coords=[pd.Index([0, 1, 2], name='i')], name='x') m.add_sos_constraints(x, sos_type=1, sos_dim='i') m.add_objective(x.sum(), sense='max') # Works with HiGHS (which doesn't support SOS natively) m.solve(solver_name='highs', reformulate_sos=True) * Documentation Summary New Section: "SOS Reformulation for Unsupported Solvers" Added a comprehensive section (~300 lines) covering: 1. Enabling Reformulation - Shows reformulate_sos=True parameter and manual reformulate_sos_constraints() method 2. Requirements - Explains finite bounds requirement for Big-M method 3. Mathematical Formulation - Clear LaTeX math for both: - SOS1: Binary indicators y_i, upper/lower linking constraints, cardinality constraint - SOS2: Segment indicators z_j, first/middle/last element constraints, cardinality constraint 4. Interpretation - Explains how the constraints work intuitively with examples 5. Auxiliary Variables and Constraints - Documents the naming convention (_sos_reform_ prefix) 6. Multi-dimensional Variables - Shows how broadcasting works 7. Edge Cases Table - Lists all handled edge cases (single-element, zero bounds, all-positive, etc.) 8. Performance Considerations - Trade-offs between native SOS and reformulation 9. Complete Example - Piecewise linear approximation of x² with HiGHS 10. API Reference - Added method signatures for: - Model.add_sos_constraints() - Model.remove_sos_constraints() - Model.reformulate_sos_constraints() - Variables.sos property * Added Tests for Multi-dimensional SOS Unit Tests - test_sos2_multidimensional: Tests that SOS2 reformulation with multi-dimensional variables (i, j) correctly creates: - Segment indicators z with shape (i: n-1, j: m) - Cardinality constraint preserves the j dimension Integration Tests - test_multidimensional_sos2_with_highs: Solves a multi-dimensional SOS2 problem with HiGHS and verifies: - Optimal objective value (4 total - two adjacent non-zeros per column) - SOS2 constraint satisfied for each j: at most 2 non-zeros, and if 2, they're adjacent Test Results test_sos1_multidimensional PASSED test_sos2_multidimensional PASSED test_multidimensional_sos1_with_highs PASSED test_multidimensional_sos2_with_highs PASSED The implementation correctly handles multi-dimensional variables by leveraging xarray's broadcasting - the SOS constraint is applied along the sos_dim for each combination of the other dimensions. * Add custom big_m parameter for SOS reformulation Allow users to specify custom Big-M values in add_sos_constraints() for tighter LP relaxations when variable bounds are conservative. - Add big_m parameter: scalar or tuple(upper, lower) - Store as variable attrs (big_m_upper, big_m_lower) - Skip bound validation when custom big_m provided - Scalar-only design ensures NetCDF persistence works correctly For per-element Big-M values, users should adjust variable bounds directly. * Add custom big_m parameter for SOS reformulation Allow users to specify custom Big-M values in add_sos_constraints() for tighter LP relaxations when variable bounds are conservative. - Add big_m parameter: scalar or tuple(upper, lower) - Store as variable attrs (big_m_upper, big_m_lower) for NetCDF persistence - Use tighter of big_m and variable bounds: min() for upper, max() for lower - Skip bound validation when custom big_m provided (allows infinite bounds) Scalar-only design ensures NetCDF persistence works correctly. For per-element Big-M values, users should adjust variable bounds directly. * Simplification summary: ┌──────────────────────┬───────────┬───────────┬───────────┐ │ File │ Before │ After │ Reduction │ ├──────────────────────┼───────────┼───────────┼───────────┤ │ sos_reformulation.py │ 377 lines │ 223 lines │ 41% │ ├──────────────────────┼───────────┼───────────┼───────────┤ │ sos-constraints.rst │ 647 lines │ 164 lines │ 75% │ └──────────────────────┴───────────┴───────────┴───────────┘ Code changes: - Merged validate_bounds_for_reformulation into compute_big_m_values - Factored out add_linking_constraints helper in SOS2 - Used np.minimum/np.maximum instead of xr.where - Kept proper docstrings with Parameters/Returns sections Doc changes: - Removed: Variable Representation, LP File Export, Common Patterns, Performance Considerations - Trimmed: Examples to one each, Mathematical formulation to equations only - Condensed: API reference, multi-dimensional explanation * Revert some docs changes to be more surgical * Add math to docs * Improve docs * Code simplifications: 1. sos_reformulation.py (230 → 203 lines): - compute_big_m_values now returns single DataArray (not tuple) - Removed all lower bound handling - only supports non-negative variables - Removed add_linking_constraints helper function - Simplified SOS1/SOS2 to only add upper linking constraints 2. model.py: - Simplified big_m parameter from float | tuple[float, float] | None to float | None - Removed big_m_lower attribute handling 3. Documentation (sos-constraints.rst): - Updated big_m type signature - Removed asymmetric Big-M example - Added explicit requirement that variables must have non-negative lower bounds 4. Tests (46 → 38 tests): - Removed tests for negative bounds - Removed tests for tuple big_m - Added tests for negative lower bound validation error Rationale: The mathematical formulation in the docs assumes x ∈ ℝⁿ₊ (non-negative reals). This matches 99%+ of SOS use cases (selection indicators, piecewise linear weights). The simplified code is now consistent with the documented formulation. * Fix mypy * Fix mypy * Add constants for sos attr keys * Add release notes * Fix SOS reformulation: undo after solve, validate big_m, vectorize - solve() now undoes SOS reformulation after solving, preserving model state - Validate big_m > 0 in add_sos_constraints (fail fast) - Vectorize SOS2 middle constraints, eliminate duplicate compute_big_m_values - Warn when reformulate_sos=True is ignored for SOS-capable solvers - Add tests for model immutability, double solve, big_m validation, undo * tiny refac, plus uncovered test * refac: move reformulating function to module * Fix SOS reformulation: rollback, skipped attrs, undo in solve, sort coords - Remove SOS attrs for skipped variables (size<=1, M==0) so solvers don't see them as SOS constraints - Wrap reformulation loop in try/except for transactional rollback - Move undo into finally block in Model.solve() for exception safety - Sort variables by coord values before building adjacency constraints to match native SOS weight-based ordering * update release notes [skip ci] --------- Co-authored-by: Fabian Hofmann <fab.hof@gmx.de>
1 parent 1b08d2b commit b7aba5f

File tree

8 files changed

+1351
-62
lines changed

8 files changed

+1351
-62
lines changed

doc/release_notes.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ Release Notes
44
Upcoming Version
55
----------------
66

7+
* Add SOS1 and SOS2 reformulations for solvers not supporting them.
8+
9+
710
Version 0.6.4
811
--------------
912

doc/sos-constraints.rst

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Method Signature
7575

7676
.. code-block:: python
7777
78-
Model.add_sos_constraints(variable, sos_type, sos_dim)
78+
Model.add_sos_constraints(variable, sos_type, sos_dim, big_m=None)
7979
8080
**Parameters:**
8181

@@ -85,6 +85,8 @@ Method Signature
8585
Type of SOS constraint (1 or 2)
8686
- ``sos_dim`` : str
8787
Name of the dimension along which the SOS constraint applies
88+
- ``big_m`` : float | None
89+
Custom Big-M value for reformulation (see :ref:`sos-reformulation`)
8890

8991
**Requirements:**
9092

@@ -254,6 +256,83 @@ SOS constraints are supported by most modern mixed-integer programming solvers t
254256
- MOSEK
255257
- MindOpt
256258

259+
For unsupported solvers, use automatic reformulation (see below).
260+
261+
.. _sos-reformulation:
262+
263+
SOS Reformulation
264+
-----------------
265+
266+
For solvers without native SOS support, linopy can reformulate SOS constraints
267+
as binary + linear constraints using the Big-M method.
268+
269+
.. code-block:: python
270+
271+
# Automatic reformulation during solve
272+
m.solve(solver_name="highs", reformulate_sos=True)
273+
274+
# Or reformulate manually
275+
m.reformulate_sos_constraints()
276+
m.solve(solver_name="highs")
277+
278+
**Requirements:**
279+
280+
- Variables must have **non-negative lower bounds** (lower >= 0)
281+
- Big-M values are derived from variable upper bounds
282+
- For infinite upper bounds, specify custom values via the ``big_m`` parameter
283+
284+
.. code-block:: python
285+
286+
# Finite bounds (default)
287+
x = m.add_variables(lower=0, upper=100, coords=[idx], name="x")
288+
m.add_sos_constraints(x, sos_type=1, sos_dim="i")
289+
290+
# Infinite upper bounds: specify Big-M
291+
x = m.add_variables(lower=0, upper=np.inf, coords=[idx], name="x")
292+
m.add_sos_constraints(x, sos_type=1, sos_dim="i", big_m=10)
293+
294+
The reformulation uses the tighter of ``big_m`` and variable upper bound.
295+
296+
Mathematical Formulation
297+
~~~~~~~~~~~~~~~~~~~~~~~~
298+
299+
**SOS1 Reformulation:**
300+
301+
Original constraint: :math:`\text{SOS1}(\{x_1, x_2, \ldots, x_n\})` means at most one
302+
:math:`x_i` can be non-zero.
303+
304+
Given :math:`x = (x_1, \ldots, x_n) \in \mathbb{R}^n_+`, introduce binary
305+
:math:`y = (y_1, \ldots, y_n) \in \{0,1\}^n`:
306+
307+
.. math::
308+
309+
x_i &\leq M_i \cdot y_i \quad \forall i \in \{1, \ldots, n\} \\
310+
x_i &\geq 0 \quad \forall i \in \{1, \ldots, n\} \\
311+
\sum_{i=1}^{n} y_i &\leq 1 \\
312+
y_i &\in \{0, 1\} \quad \forall i \in \{1, \ldots, n\}
313+
314+
where :math:`M_i \geq \sup\{x_i\}` (upper bound on :math:`x_i`).
315+
316+
**SOS2 Reformulation:**
317+
318+
Original constraint: :math:`\text{SOS2}(\{x_1, x_2, \ldots, x_n\})` means at most two
319+
:math:`x_i` can be non-zero, and if two are non-zero, they must have consecutive indices.
320+
321+
Given :math:`x = (x_1, \ldots, x_n) \in \mathbb{R}^n_+`, introduce binary
322+
:math:`y = (y_1, \ldots, y_{n-1}) \in \{0,1\}^{n-1}`:
323+
324+
.. math::
325+
326+
x_1 &\leq M_1 \cdot y_1 \\
327+
x_i &\leq M_i \cdot (y_{i-1} + y_i) \quad \forall i \in \{2, \ldots, n-1\} \\
328+
x_n &\leq M_n \cdot y_{n-1} \\
329+
x_i &\geq 0 \quad \forall i \in \{1, \ldots, n\} \\
330+
\sum_{i=1}^{n-1} y_i &\leq 1 \\
331+
y_i &\in \{0, 1\} \quad \forall i \in \{1, \ldots, n-1\}
332+
333+
where :math:`M_i \geq \sup\{x_i\}`. Interpretation: :math:`y_i = 1` activates interval
334+
:math:`[i, i+1]`, allowing :math:`x_i` and :math:`x_{i+1}` to be non-zero.
335+
257336
Common Patterns
258337
---------------
259338

linopy/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
CV_DIM,
5050
]
5151

52+
# SOS constraint attribute keys
53+
SOS_TYPE_ATTR = "sos_type"
54+
SOS_DIM_ATTR = "sos_dim"
55+
SOS_BIG_M_ATTR = "big_m_upper"
56+
5257

5358
class ModelStatus(Enum):
5459
"""

linopy/io.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
from linopy import solvers
2727
from linopy.common import to_polars
28-
from linopy.constants import CONCAT_DIM
28+
from linopy.constants import CONCAT_DIM, SOS_DIM_ATTR, SOS_TYPE_ATTR
2929
from linopy.objective import Objective
3030

3131
if TYPE_CHECKING:
@@ -371,8 +371,8 @@ def sos_to_file(
371371

372372
for name in names:
373373
var = m.variables[name]
374-
sos_type = var.attrs["sos_type"]
375-
sos_dim = var.attrs["sos_dim"]
374+
sos_type = var.attrs[SOS_TYPE_ATTR]
375+
sos_dim = var.attrs[SOS_DIM_ATTR]
376376

377377
other_dims = [dim for dim in var.labels.dims if dim != sos_dim]
378378
for var_slice in var.iterate_slices(slice_size, other_dims):
@@ -740,8 +740,8 @@ def to_gurobipy(
740740
if m.variables.sos:
741741
for var_name in m.variables.sos:
742742
var = m.variables.sos[var_name]
743-
sos_type: int = var.attrs["sos_type"] # type: ignore[assignment]
744-
sos_dim: str = var.attrs["sos_dim"] # type: ignore[assignment]
743+
sos_type: int = var.attrs[SOS_TYPE_ATTR] # type: ignore[assignment]
744+
sos_dim: str = var.attrs[SOS_DIM_ATTR] # type: ignore[assignment]
745745

746746
def add_sos(s: xr.DataArray, sos_type: int, sos_dim: str) -> None:
747747
s = s.squeeze()

linopy/model.py

Lines changed: 102 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
GREATER_EQUAL,
4040
HELPER_DIMS,
4141
LESS_EQUAL,
42+
SOS_BIG_M_ATTR,
43+
SOS_DIM_ATTR,
44+
SOS_TYPE_ATTR,
4245
TERM_DIM,
4346
ModelStatus,
4447
TerminationCondition,
@@ -66,6 +69,10 @@
6669
IO_APIS,
6770
available_solvers,
6871
)
72+
from linopy.sos_reformulation import (
73+
reformulate_sos_constraints,
74+
undo_sos_reformulation,
75+
)
6976
from linopy.types import (
7077
ConstantLike,
7178
ConstraintLike,
@@ -591,6 +598,7 @@ def add_sos_constraints(
591598
variable: Variable,
592599
sos_type: Literal[1, 2],
593600
sos_dim: str,
601+
big_m: float | None = None,
594602
) -> None:
595603
"""
596604
Add an sos1 or sos2 constraint for one dimension of a variable
@@ -604,15 +612,26 @@ def add_sos_constraints(
604612
Type of SOS
605613
sos_dim : str
606614
Which dimension of variable to add SOS constraint to
615+
big_m : float | None, optional
616+
Big-M value for SOS reformulation. Only used when reformulating
617+
SOS constraints for solvers that don't support them natively.
618+
619+
- None (default): Use variable upper bounds as Big-M
620+
- float: Custom Big-M value
621+
622+
The reformulation uses the tighter of big_m and variable upper bound:
623+
M = min(big_m, var.upper).
624+
625+
Tighter Big-M values improve LP relaxation quality and solve time.
607626
"""
608627
if sos_type not in (1, 2):
609628
raise ValueError(f"sos_type must be 1 or 2, got {sos_type}")
610629
if sos_dim not in variable.dims:
611630
raise ValueError(f"sos_dim must name a variable dimension, got {sos_dim}")
612631

613-
if "sos_type" in variable.attrs or "sos_dim" in variable.attrs:
614-
existing_sos_type = variable.attrs.get("sos_type")
615-
existing_sos_dim = variable.attrs.get("sos_dim")
632+
if SOS_TYPE_ATTR in variable.attrs or SOS_DIM_ATTR in variable.attrs:
633+
existing_sos_type = variable.attrs.get(SOS_TYPE_ATTR)
634+
existing_sos_dim = variable.attrs.get(SOS_DIM_ATTR)
616635
raise ValueError(
617636
f"variable already has an sos{existing_sos_type} constraint on {existing_sos_dim}"
618637
)
@@ -624,7 +643,13 @@ def add_sos_constraints(
624643
f"but got {variable.coords[sos_dim].dtype}"
625644
)
626645

627-
variable.attrs.update(sos_type=sos_type, sos_dim=sos_dim)
646+
attrs_update: dict[str, Any] = {SOS_TYPE_ATTR: sos_type, SOS_DIM_ATTR: sos_dim}
647+
if big_m is not None:
648+
if big_m <= 0:
649+
raise ValueError(f"big_m must be positive, got {big_m}")
650+
attrs_update[SOS_BIG_M_ATTR] = float(big_m)
651+
652+
variable.attrs.update(attrs_update)
628653

629654
def add_constraints(
630655
self,
@@ -891,18 +916,22 @@ def remove_sos_constraints(self, variable: Variable) -> None:
891916
-------
892917
None.
893918
"""
894-
if "sos_type" not in variable.attrs or "sos_dim" not in variable.attrs:
919+
if SOS_TYPE_ATTR not in variable.attrs or SOS_DIM_ATTR not in variable.attrs:
895920
raise ValueError(f"Variable '{variable.name}' has no SOS constraints")
896921

897-
sos_type = variable.attrs["sos_type"]
898-
sos_dim = variable.attrs["sos_dim"]
922+
sos_type = variable.attrs[SOS_TYPE_ATTR]
923+
sos_dim = variable.attrs[SOS_DIM_ATTR]
924+
925+
del variable.attrs[SOS_TYPE_ATTR], variable.attrs[SOS_DIM_ATTR]
899926

900-
del variable.attrs["sos_type"], variable.attrs["sos_dim"]
927+
variable.attrs.pop(SOS_BIG_M_ATTR, None)
901928

902929
logger.debug(
903930
f"Removed sos{sos_type} constraint on {sos_dim} from {variable.name}"
904931
)
905932

933+
reformulate_sos_constraints = reformulate_sos_constraints
934+
906935
def remove_objective(self) -> None:
907936
"""
908937
Remove the objective's linear expression from the model.
@@ -1187,6 +1216,7 @@ def solve(
11871216
remote: RemoteHandler | OetcHandler = None, # type: ignore
11881217
progress: bool | None = None,
11891218
mock_solve: bool = False,
1219+
reformulate_sos: bool = False,
11901220
**solver_options: Any,
11911221
) -> tuple[str, str]:
11921222
"""
@@ -1256,6 +1286,11 @@ def solve(
12561286
than 10000 variables and constraints.
12571287
mock_solve : bool, optional
12581288
Whether to run a mock solve. This will skip the actual solving. Variables will be set to have dummy values
1289+
reformulate_sos : bool, optional
1290+
Whether to automatically reformulate SOS constraints as binary + linear
1291+
constraints for solvers that don't support them natively.
1292+
This uses the Big-M method and requires all SOS variables to have finite bounds.
1293+
Default is False.
12591294
**solver_options : kwargs
12601295
Options passed to the solver.
12611296
@@ -1353,11 +1388,25 @@ def solve(
13531388
f"Solver {solver_name} does not support quadratic problems."
13541389
)
13551390

1356-
# SOS constraints are not supported by all solvers
1357-
if self.variables.sos and not solver_supports(
1358-
solver_name, SolverFeature.SOS_CONSTRAINTS
1359-
):
1360-
raise ValueError(f"Solver {solver_name} does not support SOS constraints.")
1391+
sos_reform_result = None
1392+
if self.variables.sos:
1393+
if reformulate_sos and not solver_supports(
1394+
solver_name, SolverFeature.SOS_CONSTRAINTS
1395+
):
1396+
logger.info(f"Reformulating SOS constraints for solver {solver_name}")
1397+
sos_reform_result = reformulate_sos_constraints(self)
1398+
elif reformulate_sos and solver_supports(
1399+
solver_name, SolverFeature.SOS_CONSTRAINTS
1400+
):
1401+
logger.warning(
1402+
f"Solver {solver_name} supports SOS natively; "
1403+
"reformulate_sos=True is ignored."
1404+
)
1405+
elif not solver_supports(solver_name, SolverFeature.SOS_CONSTRAINTS):
1406+
raise ValueError(
1407+
f"Solver {solver_name} does not support SOS constraints. "
1408+
"Use reformulate_sos=True or a solver that supports SOS (gurobi, cplex)."
1409+
)
13611410

13621411
try:
13631412
solver_class = getattr(solvers, f"{solvers.SolverName(solver_name).name}")
@@ -1406,44 +1455,51 @@ def solve(
14061455
if fn is not None and (os.path.exists(fn) and not keep_files):
14071456
os.remove(fn)
14081457

1409-
result.info()
1410-
1411-
self.objective._value = result.solution.objective
1412-
self.status = result.status.status.value
1413-
self.termination_condition = result.status.termination_condition.value
1414-
self.solver_model = result.solver_model
1415-
self.solver_name = solver_name
1416-
1417-
if not result.status.is_ok:
1418-
return result.status.status.value, result.status.termination_condition.value
1458+
try:
1459+
result.info()
1460+
1461+
self.objective._value = result.solution.objective
1462+
self.status = result.status.status.value
1463+
self.termination_condition = result.status.termination_condition.value
1464+
self.solver_model = result.solver_model
1465+
self.solver_name = solver_name
1466+
1467+
if not result.status.is_ok:
1468+
return (
1469+
result.status.status.value,
1470+
result.status.termination_condition.value,
1471+
)
14191472

1420-
# map solution and dual to original shape which includes missing values
1421-
sol = result.solution.primal.copy()
1422-
sol = set_int_index(sol)
1423-
sol.loc[-1] = nan
1473+
# map solution and dual to original shape which includes missing values
1474+
sol = result.solution.primal.copy()
1475+
sol = set_int_index(sol)
1476+
sol.loc[-1] = nan
14241477

1425-
for name, var in self.variables.items():
1426-
idx = np.ravel(var.labels)
1427-
try:
1428-
vals = sol[idx].values.reshape(var.labels.shape)
1429-
except KeyError:
1430-
vals = sol.reindex(idx).values.reshape(var.labels.shape)
1431-
var.solution = xr.DataArray(vals, var.coords)
1432-
1433-
if not result.solution.dual.empty:
1434-
dual = result.solution.dual.copy()
1435-
dual = set_int_index(dual)
1436-
dual.loc[-1] = nan
1437-
1438-
for name, con in self.constraints.items():
1439-
idx = np.ravel(con.labels)
1478+
for name, var in self.variables.items():
1479+
idx = np.ravel(var.labels)
14401480
try:
1441-
vals = dual[idx].values.reshape(con.labels.shape)
1481+
vals = sol[idx].values.reshape(var.labels.shape)
14421482
except KeyError:
1443-
vals = dual.reindex(idx).values.reshape(con.labels.shape)
1444-
con.dual = xr.DataArray(vals, con.labels.coords)
1483+
vals = sol.reindex(idx).values.reshape(var.labels.shape)
1484+
var.solution = xr.DataArray(vals, var.coords)
1485+
1486+
if not result.solution.dual.empty:
1487+
dual = result.solution.dual.copy()
1488+
dual = set_int_index(dual)
1489+
dual.loc[-1] = nan
1490+
1491+
for name, con in self.constraints.items():
1492+
idx = np.ravel(con.labels)
1493+
try:
1494+
vals = dual[idx].values.reshape(con.labels.shape)
1495+
except KeyError:
1496+
vals = dual.reindex(idx).values.reshape(con.labels.shape)
1497+
con.dual = xr.DataArray(vals, con.labels.coords)
14451498

1446-
return result.status.status.value, result.status.termination_condition.value
1499+
return result.status.status.value, result.status.termination_condition.value
1500+
finally:
1501+
if sos_reform_result is not None:
1502+
undo_sos_reformulation(self, sos_reform_result)
14471503

14481504
def _mock_solve(
14491505
self,

0 commit comments

Comments
 (0)