Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Upcoming Version
* 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 <https://github.com/PyPSA/linopy/issues/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 <https://github.com/PyPSA/linopy/issues/981>`__)
* ``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 <https://github.com/PyPSA/linopy/pull/899>`__)
* ``licensed_solvers`` and ``check_solver_licenses()`` no longer report Xpress as licensed when its licence is rejected, e.g. a licence that only covers an older Xpress release. Xpress now probes its licence with ``xpress.init()``. (`#994 <https://github.com/PyPSA/linopy/issues/994>`__)

**Breaking Changes**

Expand Down
4 changes: 4 additions & 0 deletions linopy/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2665,6 +2665,10 @@ class Xpress(Solver[None]):
def is_available(cls) -> bool:
return _has_module("xpress")

@classmethod
def _license_probe(cls) -> None:
xpress.init()

def _apply_var_bounds(
self, ctx: Any, indices: np.ndarray, lower: np.ndarray, upper: np.ndarray
) -> None:
Expand Down
20 changes: 20 additions & 0 deletions test/test_available_solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,23 @@ class _FakeMindoptpy:
cls._license_probe()

assert events == ["env_init", "env_dispose"]


def test_xpress_license_probe_failure_marks_unlicensed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cls = _solver_class_for("xpress")
assert cls is not None
monkeypatch.setattr(cls, "is_available", classmethod(lambda c: True))

class _FakeXpress:
@staticmethod
def init() -> None:
raise RuntimeError("licensing error 21")

monkeypatch.setattr(solvers_mod, "xpress", _FakeXpress, raising=False)

status = cls.license_status()

assert status.ok is False
assert "licensing error 21" in (status.message or "")
6 changes: 3 additions & 3 deletions test/test_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import pytest
import xarray as xr

from linopy import EQUAL, GREATER_EQUAL, LESS_EQUAL, Model, Variable, available_solvers
from linopy import EQUAL, GREATER_EQUAL, LESS_EQUAL, Model, Variable, licensed_solvers
from linopy.testing import assert_conequal

# Test model functions
Expand Down Expand Up @@ -490,9 +490,9 @@ def test_constraint_rhs_extra_dims_broadcasts(self, v: Variable) -> None:
assert "extra" in c.dims

def test_subset_constraint_solve_integration(self) -> None:
if not available_solvers:
if not licensed_solvers:
pytest.skip("No solver available")
solver = "highs" if "highs" in available_solvers else available_solvers[0]
solver = "highs" if "highs" in licensed_solvers else licensed_solvers[0]
m = Model()
coords = pd.RangeIndex(5, name="i")
x = m.add_variables(lower=0, upper=100, coords=[coords], name="x")
Expand Down
6 changes: 3 additions & 3 deletions test/test_csr.py
Original file line number Diff line number Diff line change
Expand Up @@ -1745,7 +1745,7 @@ def test_objective_stays_csr_and_exports_like_dense(
assert rs.objective.expression.attrs["name"] == "objective"


@pytest.mark.skipif("highs" not in linopy.available_solvers, reason="needs highs")
@pytest.mark.skipif("highs" not in linopy.licensed_solvers, reason="needs highs")
@pytest.mark.parametrize("io_api", ["lp", "direct"])
@pytest.mark.parametrize("build", list(SPARSE_BUILDS))
def test_sparse_objective_solves_like_dense(build: str, io_api: str) -> None:
Expand Down Expand Up @@ -2380,7 +2380,7 @@ def test_frozen_soften_max_sense_with_array_penalty() -> None:
assert_cells_equal(obj, expected_objective, ())


@pytest.mark.skipif("highs" not in linopy.available_solvers, reason="needs highs")
@pytest.mark.skipif("highs" not in linopy.licensed_solvers, reason="needs highs")
@pytest.mark.parametrize("sign", ["<=", ">=", "=="])
def test_frozen_soften_solves_like_dense(sign: str) -> None:
require_v1()
Expand Down Expand Up @@ -2454,7 +2454,7 @@ def frozen_model(soften: bool) -> tuple[Model, CSRConstraint]:
return c.m, con


@pytest.mark.skipif("highs" not in linopy.available_solvers, reason="needs highs")
@pytest.mark.skipif("highs" not in linopy.licensed_solvers, reason="needs highs")
@pytest.mark.parametrize("include_solution", [True, False])
@pytest.mark.parametrize("deep", [True, False])
def test_copy_keeps_frozen_constraints(deep: bool, include_solution: bool) -> None:
Expand Down
6 changes: 3 additions & 3 deletions test/test_expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from linopy import Model
from linopy.expressions import Expressions, LinearExpression, QuadraticExpression
from linopy.solvers import available_solvers
from linopy.solvers import licensed_solvers
from linopy.testing import assert_linequal


Expand Down Expand Up @@ -128,14 +128,14 @@ def test_model_repr_contains_expressions(m: Model) -> None:
assert "* expr_x" in r


@pytest.mark.skipif(not available_solvers, reason="No solver available")
@pytest.mark.skipif(not licensed_solvers, reason="No solver available")
def test_expressions_solution() -> None:
m = Model()
x = m.add_variables(lower=0, coords=[pd.RangeIndex(3, name="first")], name="x")
m.add_constraints(x >= 2)
m.add_expressions(2 * x, name="double_x")
m.add_objective(x.sum())
m.solve(available_solvers[0])
m.solve(licensed_solvers[0])

sol = m.expressions.solution
assert isinstance(sol, xr.Dataset)
Expand Down
4 changes: 2 additions & 2 deletions test/test_indicator_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
import pytest

import linopy
from linopy import Model, available_solvers
from linopy import Model, available_solvers, licensed_solvers
from linopy.constraints import Constraint, CSRConstraint
from linopy.variables import Variable

requires_gurobi = pytest.mark.skipif(
"gurobi" not in available_solvers, reason="Gurobi not installed"
"gurobi" not in licensed_solvers, reason="Gurobi not installed"
)


Expand Down
18 changes: 9 additions & 9 deletions test/test_infeasibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pandas as pd
import pytest

from linopy import Model, available_solvers
from linopy import Model, licensed_solvers


class TestInfeasibility:
Expand Down Expand Up @@ -80,7 +80,7 @@ def test_simple_infeasibility_detection(
self, simple_infeasible_model: Model, solver: str
) -> None:
"""Test basic infeasibility detection."""
if solver not in available_solvers:
if solver not in licensed_solvers:
pytest.skip(f"{solver} not available")

m = simple_infeasible_model
Expand All @@ -103,7 +103,7 @@ def test_complex_infeasibility_detection(
self, complex_infeasible_model: Model, solver: str
) -> None:
"""Test infeasibility detection on more complex model."""
if solver not in available_solvers:
if solver not in licensed_solvers:
pytest.skip(f"{solver} not available")

m = complex_infeasible_model
Expand All @@ -126,7 +126,7 @@ def test_multi_dimensional_infeasibility(
self, multi_dimensional_infeasible_model: Model, solver: str
) -> None:
"""Test infeasibility detection on multi-dimensional model."""
if solver not in available_solvers:
if solver not in licensed_solvers:
pytest.skip(f"{solver} not available")

m = multi_dimensional_infeasible_model
Expand Down Expand Up @@ -155,7 +155,7 @@ def test_unsolved_model_error(self) -> None:
@pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"])
def test_no_solver_model_error(self, solver: str) -> None:
"""Test error when solver model is not available after solving."""
if solver not in available_solvers:
if solver not in licensed_solvers:
pytest.skip(f"{solver} not available")

m = Model()
Expand All @@ -176,7 +176,7 @@ def test_no_solver_model_error(self, solver: str) -> None:
@pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"])
def test_feasible_model_iis(self, solver: str) -> None:
"""Test IIS computation on a feasible model."""
if solver not in available_solvers:
if solver not in licensed_solvers:
pytest.skip(f"{solver} not available")

m = Model()
Expand Down Expand Up @@ -213,7 +213,7 @@ def test_unsupported_solver_error(self) -> None:
m.add_objective(1 * x)

# Use a solver that doesn't support IIS
if "cbc" in available_solvers:
if "cbc" in licensed_solvers:
status, condition = m.solve(solver_name="cbc")
assert "infeasible" in condition

Expand All @@ -225,7 +225,7 @@ def test_deprecated_method(
self, simple_infeasible_model: Model, solver: str
) -> None:
"""Test that deprecated method still works."""
if solver not in available_solvers:
if solver not in licensed_solvers:
pytest.skip(f"{solver} not available")

m = simple_infeasible_model
Expand Down Expand Up @@ -263,7 +263,7 @@ def test_masked_constraint_infeasibility(
The enumeration creates positions [0, 1, 2, ...] that should correspond
to the actual constraint labels which may have gaps like [0, 2, 4, 6].
"""
if solver not in available_solvers:
if solver not in licensed_solvers:
pytest.skip(f"{solver} not available")

m = Model()
Expand Down
33 changes: 17 additions & 16 deletions test/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import pytest
import xarray as xr

from linopy import LESS_EQUAL, Model, available_solvers, read_netcdf
from linopy import LESS_EQUAL, Model, available_solvers, licensed_solvers, read_netcdf
from linopy.constants import FACTOR_DIM
from linopy.expressions import LinearExpression, QuadraticExpression
from linopy.io import CONTAINER_ORDER_ATTR, signed_number
Expand Down Expand Up @@ -552,7 +552,7 @@ def test_read_netcdf_without_version_stamp(model: Model, tmp_path: Path) -> None
assert_model_equal(model, read_netcdf(fn_legacy))


@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed")
@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed")
def test_to_file_lp(model: Model, tmp_path: Path) -> None:
import gurobipy

Expand All @@ -562,7 +562,7 @@ def test_to_file_lp(model: Model, tmp_path: Path) -> None:
gurobipy.read(str(fn))


@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed")
@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed")
def test_to_file_lp_explicit_coordinate_names(model: Model, tmp_path: Path) -> None:
import gurobipy

Expand All @@ -572,7 +572,7 @@ def test_to_file_lp_explicit_coordinate_names(model: Model, tmp_path: Path) -> N
gurobipy.read(str(fn))


@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed")
@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed")
def test_to_file_lp_None(model: Model) -> None:
import gurobipy

Expand All @@ -584,7 +584,7 @@ def test_to_file_lp_None(model: Model) -> None:


@pytest.mark.skipif(
not {"gurobi", "highs"}.issubset(available_solvers),
not {"gurobi", "highs"}.issubset(licensed_solvers),
reason="Gurobipy of highspy not installed",
)
def test_to_file_mps(model: Model, tmp_path: Path) -> None:
Expand All @@ -603,13 +603,13 @@ def test_to_file_invalid(model: Model, tmp_path: Path) -> None:
model.to_file(fn)


@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed")
@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed")
def test_to_gurobipy(model: Model) -> None:
gm = model.to_gurobipy()
assert gm.NumVars > 0


@pytest.mark.skipif("highs" not in available_solvers, reason="Highspy not installed")
@pytest.mark.skipif("highs" not in licensed_solvers, reason="Highspy not installed")
def test_to_highspy(model: Model) -> None:
h = model.to_highspy()
assert h.getLp().num_col_ > 0
Expand All @@ -621,7 +621,7 @@ def test_to_mosek(model: Model) -> None:
assert task.getnumvar() > 0


@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed")
@pytest.mark.skipif("xpress" not in licensed_solvers, reason="Xpress not installed")
def test_to_xpress(model: Model) -> None:
p = model.to_xpress()
assert p.attributes.cols > 0
Expand Down Expand Up @@ -675,7 +675,8 @@ def test_to_solver_set_names(
set_names: bool | None,
expected: bool,
) -> None:
if solver not in available_solvers:
usable = available_solvers if solver == "mosek" else licensed_solvers
if solver not in usable:
pytest.skip(f"{solver} not installed")
to_solver, names = SOLVER_IO[solver]
named = to_solver(model, set_names=True)
Expand All @@ -684,13 +685,13 @@ def test_to_solver_set_names(
assert (names(built) == names(named)) == expected


@pytest.mark.skipif("cupdlpx" not in available_solvers, reason="cuPDLPx not installed")
@pytest.mark.skipif("cupdlpx" not in licensed_solvers, reason="cuPDLPx not installed")
def test_to_cupdlpx(model: Model) -> None:
cu = model.to_cupdlpx()
assert cu is not None


@pytest.mark.skipif("cuopt" not in available_solvers, reason="cuOpt not installed")
@pytest.mark.skipif("cuopt" not in licensed_solvers, reason="cuOpt not installed")
def test_to_cuopt(model: Model) -> None:
dm = model.to_cuopt()
assert len(dm.get_objective_coefficients()) > 0
Expand All @@ -701,7 +702,7 @@ def test_model_set_names_in_solver_io_default() -> None:
assert Model().set_names_in_solver_io is False


@pytest.mark.skipif("highs" not in available_solvers, reason="Highspy not installed")
@pytest.mark.skipif("highs" not in licensed_solvers, reason="Highspy not installed")
def test_model_set_names_in_solver_io(model: Model) -> None:
model.solve(solver_name="highs", io_api="direct")
expected_obj = model.objective.value
Expand Down Expand Up @@ -781,7 +782,7 @@ def test_mixed_values_including_negative_zero(self) -> None:
assert "+-" not in v


@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed")
@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed")
def test_to_file_lp_with_negative_zero_bounds(tmp_path: Path) -> None:
"""
Test that LP files with negative zero bounds are valid.
Expand Down Expand Up @@ -815,7 +816,7 @@ def test_to_file_lp_with_negative_zero_bounds(tmp_path: Path) -> None:
gurobipy.read(str(fn))


@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed")
@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed")
def test_to_file_lp_with_negative_zero_coefficients(tmp_path: Path) -> None:
"""
Test that LP files with negative zero coefficients are valid.
Expand Down Expand Up @@ -926,12 +927,12 @@ def test_tightened_bounds_written(
for label in m.variables["x"].labels.values[2:]:
assert f"x{label} <= +0.0" in bounds_section

@pytest.mark.skipif(not available_solvers, reason="No solver installed")
@pytest.mark.skipif(not licensed_solvers, reason="No solver installed")
def test_lp_and_direct_agree(
self, make_tightened_model: Callable[[], Model]
) -> None:
"""LP and direct paths see the same feasible set for tightened binaries."""
solver = available_solvers[0]
solver = licensed_solvers[0]

m_direct = make_tightened_model()
m_direct.solve(solver_name=solver, io_api="direct")
Expand Down
4 changes: 2 additions & 2 deletions test/test_mi_feasibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import pytest
import xarray as xr

from linopy import Model, Variable, available_solvers
from linopy import Model, Variable, licensed_solvers
from linopy.testing import assert_linequal

PERIODS = [2020, 2030]
Expand All @@ -31,7 +31,7 @@
DEMAND = {2020: 5.0, 2030: 7.0}

needs_highs = pytest.mark.skipif(
"highs" not in available_solvers, reason="highs solver not available"
"highs" not in licensed_solvers, reason="highs solver not available"
)


Expand Down
4 changes: 2 additions & 2 deletions test/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import pytest
import xarray as xr

from linopy import EQUAL, Model, available_solvers
from linopy import EQUAL, Model, licensed_solvers
from linopy.testing import (
assert_conequal,
assert_equal,
Expand Down Expand Up @@ -425,7 +425,7 @@ def test_model_copy_preserves_quadratic_objective(
assert_model_equal(m, c)


@pytest.mark.skipif(not available_solvers, reason="No solver installed")
@pytest.mark.skipif(not licensed_solvers, reason="No solver installed")
class TestModelCopySolved:
def test_model_deepcopy_protocol_excludes_solution(
self, solved_copy_test_model: Model
Expand Down
Loading
Loading