diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 42e8bee4..d38abd7d 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -91,6 +91,7 @@ Upcoming Version * ``linopy.merge`` with ``join="outer"``, ``"left"`` or ``"right"`` no longer raises an xarray ``MergeError`` when only one operand carries an auxiliary coordinate on a joined dimension. The constant was reindexed with a fill value of ``0`` that was also written into the auxiliary coordinate, so it no longer matched the coefficients' copy. The coordinate now follows its rows onto the joined grid and is NaN where no operand provides it, under both semantics. The same holds for ``.add`` / ``.sub`` / ``.mul`` / ``.div`` with a constant and a reindexing ``join=``. * A frozen constraint caches the label-to-position mapping of its matrix columns by weak reference and only rebuilds it when the constraint or the set of variables changes. While a persistent snapshot holds the arrays, repeated matrix assembly on an unchanged model returns the same objects, so the snapshot diff can again skip the comparison of untouched frozen constraints by object identity; one-off exports such as ``to_file`` retain no extra memory. (`#933 `__) * ``Model.copy`` (and the ``copy.copy``/``copy.deepcopy`` protocols) keeps frozen constraints as ``CSRConstraint`` instead of rebuilding them as dense ``Constraint``; ``deep=True`` copies their arrays, and duals are copied only with ``include_solution=True``. A CSR-backed objective is now deep-copied with ``deep=True`` as well. The auxiliary coordinates of a CSR grid are stored read-only, so a copy can share them safely. (`#981 `__) +* Solving a MILP from a file with SCIP no longer asks SCIP for dual values. A MILP has none, so SCIP printed errors such as ``cannot get reduced costs, because node LP is not processed`` and linopy returned meaningless duals. They are now left empty, as with the other solvers. The SCIP interface also works with PySCIPOpt older than 5 again: it read the original constraints with ``getConss(False)``, whose argument those versions don't accept, so every solve raised a ``TypeError``. * ``Solver.close()`` no longer leaves dangling native handles behind. The solver model is now dropped before the environment that owns it, instead of after. And the COPT and MindOpt file interfaces no longer hand back a model they already disposed: after a file-based COPT or MindOpt solve, ``model.solver_model`` is ``None`` rather than a handle into freed memory. (`#899 `__) **Breaking Changes** diff --git a/linopy/solvers.py b/linopy/solvers.py index 6e1ec74d..9e508bfe 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2548,6 +2548,10 @@ def _run_file( m = scip.Model() m.readProblem(path_to_string(problem_fn)) + # Read the original problem before solving. Afterwards this needs + # `getConss(transformed=False)`, which PySCIPOpt < 5 doesn't have. + original_cons = m.getConss() + is_mip = m.getNIntVars() + m.getNBinVars() > 0 if self.solver_options is not None: emphasis = self.solver_options.pop("setEmphasis", None) @@ -2598,17 +2602,18 @@ def get_solver_solution() -> Solution: self._n_vars, ) - cons = m.getConss(False) - if len(cons) != 0: - kept_cons = [c for c in cons if c.name not in vars_to_ignore] + if is_mip: + # A MILP has no dual values, and asking SCIP for them prints + # errors and returns meaningless values + logger.warning("Dual values of MILP couldn't be parsed") + dual = np.array([], dtype=float) + else: + kept_cons = [c for c in original_cons if c.name not in vars_to_ignore] dual = _solution_from_names( np.array([m.getDualSolVal(c) for c in kept_cons], dtype=float), [c.name for c in kept_cons], self._n_cons, ) - else: - logger.warning("Dual values not available (is this an MILP?)") - dual = np.array([], dtype=float) return Solution(sol, dual, objective) diff --git a/test/test_solvers.py b/test/test_solvers.py index 4a90b6a0..1a01e425 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -344,6 +344,44 @@ def test_free_mps_solution_parsing(solver: str, tmp_path: Path) -> None: assert result.solution.objective == 30.0 +@pytest.mark.skipif( + "scip" not in set(solvers.licensed_solvers), reason="SCIP is not installed" +) +def test_scip_lp_returns_duals() -> None: + m = Model() + x = m.add_variables(lower=0, name="x") + y = m.add_variables(lower=0, name="y") + m.add_constraints(x + y >= 4, name="c1") + m.add_constraints(x + 3 * y >= 6, name="c2") + m.add_objective(2 * x + 3 * y) + status, _ = m.solve("scip") + + assert status == "ok" + assert m.objective.value == pytest.approx(9) + assert m.constraints["c1"].dual.item() == pytest.approx(1.5) + assert m.constraints["c2"].dual.item() == pytest.approx(0.5) + + +@pytest.mark.skipif( + "scip" not in set(solvers.licensed_solvers), reason="SCIP is not installed" +) +def test_scip_milp_returns_no_duals() -> None: + m = Model() + x = m.add_variables(lower=0, integer=True, name="x") + y = m.add_variables(lower=0, name="y") + m.add_constraints(x + y >= 4.5, name="c1") + m.add_constraints(x + 3 * y >= 6, name="c2") + m.add_constraints(x <= 3.5, name="c3") + m.add_objective(2 * x + 3 * y) + status, _ = m.solve("scip") + + assert status == "ok" + assert m.objective.value == pytest.approx(10.5) + # A MILP has no dual values + with pytest.raises(AttributeError, match="dual"): + _ = m.constraints["c1"].dual + + @pytest.mark.skipif( "knitro" not in set(solvers.licensed_solvers), reason="Knitro is not installed" )