From 1df6b30d36bf067deea82d815a0226e806fbf397 Mon Sep 17 00:00:00 2001 From: Fabrizio Finozzi <167071962+finozzifa@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:52:53 +0100 Subject: [PATCH 1/8] Expose the knitro context (#600) * code: expose knitro context and modify _extract_values * doc: update release_notes.rst * code: include pre-commit checks --- doc/release_notes.rst | 3 +++ linopy/solvers.py | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 7731443b..f9829e28 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,9 @@ Release Notes Upcoming Version ---------------- +* Expose the knitro context to allow for more flexible use of the knitro python API. + + Version 0.6.4 -------------- diff --git a/linopy/solvers.py b/linopy/solvers.py index 16c07932..474459fe 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1745,7 +1745,7 @@ def get_solver_solution() -> Solution: return Result(status, solution, m) -KnitroResult = namedtuple("KnitroResult", "reported_runtime") +KnitroResult = namedtuple("KnitroResult", "knitro_context reported_runtime") class Knitro(Solver[None]): @@ -1808,7 +1808,13 @@ def _extract_values( if n == 0: return pd.Series(dtype=float) - values = get_values_fn(kc, n - 1) + try: + # Compatible with KNITRO >= 15 + values = get_values_fn(kc) + except TypeError: + # Fallback for older wrappers requiring explicit indices + values = get_values_fn(kc, list(range(n))) + names = list(get_names_fn(kc)) return pd.Series(values, index=names, dtype=float) @@ -1931,12 +1937,14 @@ def get_solver_solution() -> Solution: knitro.KN_write_mps_file(kc, path_to_string(solution_fn)) return Result( - status, solution, KnitroResult(reported_runtime=reported_runtime) + status, + solution, + KnitroResult(knitro_context=kc, reported_runtime=reported_runtime), ) finally: - with contextlib.suppress(Exception): - knitro.KN_free(kc) + # Intentionally keep the Knitro context alive; do not free `kc` here. + pass mosek_bas_re = re.compile(r" (XL|XU)\s+([^ \t]+)\s+([^ \t]+)| (LL|UL|BS)\s+([^ \t]+)") From 4abf542497774d746a83edc13e5c1a8442d9a78b Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 4 Mar 2026 08:01:55 +0100 Subject: [PATCH 2/8] update release notes for v0.6.5 --- doc/release_notes.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index f9829e28..c35249b5 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -1,8 +1,8 @@ Release Notes ============= -Upcoming Version ----------------- +Version 0.6.5 +------------- * Expose the knitro context to allow for more flexible use of the knitro python API. From e42de8a812f779f27b9cf59fc698cb8ec9a71954 Mon Sep 17 00:00:00 2001 From: Fabrizio Finozzi <167071962+finozzifa@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:57:28 +0100 Subject: [PATCH 3/8] Re-introduce knitro context closure and export solver quantities (#633) * code: re-introduce knitro context closure and export solver quantities * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * code: place quantities calculation elsewhere * code: place quantities calculation elsewhere * code: add new quantities extracted from knitro context * code: add int * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * code: add release notes --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Fabian Hofmann --- doc/release_notes.rst | 6 +++++ linopy/solvers.py | 57 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index c35249b5..ec9ae84f 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -1,6 +1,12 @@ Release Notes ============= +Version 0.6.6 +------------- + +* Free the knitro context and compute necessary quantities within linopy. Knitro context is not exposed anymore. + + Version 0.6.5 ------------- diff --git a/linopy/solvers.py b/linopy/solvers.py index 474459fe..da3c55d8 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1745,7 +1745,10 @@ def get_solver_solution() -> Solution: return Result(status, solution, m) -KnitroResult = namedtuple("KnitroResult", "knitro_context reported_runtime") +KnitroResult = namedtuple( + "KnitroResult", + "reported_runtime mip_relaxation_bnd mip_number_nodes mip_number_solves mip_rel_gap mip_abs_gap abs_feas_error rel_feas_error abs_opt_error rel_opt_error n_vars n_cons n_integer_vars n_continuous_vars", +) class Knitro(Solver[None]): @@ -1893,8 +1896,38 @@ def solve_problem_from_file( ret = int(knitro.KN_solve(kc)) reported_runtime: float | None = None + mip_relaxation_bnd: float | None = None + mip_number_nodes: int | None = None + mip_number_solves: int | None = None + mip_rel_gap: float | None = None + mip_abs_gap: float | None = None + abs_feas_error: float | None = None + rel_feas_error: float | None = None + abs_opt_error: float | None = None + rel_opt_error: float | None = None + n_vars: int | None = None + n_cons: int | None = None + n_integer_vars: int | None = None + n_continuous_vars: int | None = None with contextlib.suppress(Exception): reported_runtime = float(knitro.KN_get_solve_time_real(kc)) + mip_relaxation_bnd = float(knitro.KN_get_mip_relaxation_bnd(kc)) + mip_number_nodes = int(knitro.KN_get_mip_number_nodes(kc)) + mip_number_solves = int(knitro.KN_get_mip_number_solves(kc)) + mip_rel_gap = float(knitro.KN_get_mip_rel_gap(kc)) + mip_abs_gap = float(knitro.KN_get_mip_abs_gap(kc)) + abs_feas_error = float(knitro.KN_get_abs_feas_error(kc)) + rel_feas_error = float(knitro.KN_get_rel_feas_error(kc)) + abs_opt_error = float(knitro.KN_get_abs_opt_error(kc)) + rel_opt_error = float(knitro.KN_get_rel_opt_error(kc)) + n_vars = int(knitro.KN_get_number_vars(kc)) + n_cons = int(knitro.KN_get_number_cons(kc)) + var_types = list(knitro.KN_get_var_types(kc)) + n_integer_vars = int( + var_types.count(knitro.KN_VARTYPE_INTEGER) + + var_types.count(knitro.KN_VARTYPE_BINARY) + ) + n_continuous_vars = int(var_types.count(knitro.KN_VARTYPE_CONTINUOUS)) if ret in CONDITION_MAP: termination_condition = CONDITION_MAP[ret] @@ -1939,12 +1972,26 @@ def get_solver_solution() -> Solution: return Result( status, solution, - KnitroResult(knitro_context=kc, reported_runtime=reported_runtime), + KnitroResult( + reported_runtime=reported_runtime, + mip_relaxation_bnd=mip_relaxation_bnd, + mip_number_nodes=mip_number_nodes, + mip_number_solves=mip_number_solves, + mip_rel_gap=mip_rel_gap, + mip_abs_gap=mip_abs_gap, + abs_feas_error=abs_feas_error, + rel_feas_error=rel_feas_error, + abs_opt_error=abs_opt_error, + rel_opt_error=rel_opt_error, + n_vars=n_vars, + n_cons=n_cons, + n_integer_vars=n_integer_vars, + n_continuous_vars=n_continuous_vars, + ), ) - finally: - # Intentionally keep the Knitro context alive; do not free `kc` here. - pass + with contextlib.suppress(Exception): + knitro.KN_free(kc) mosek_bas_re = re.compile(r" (XL|XU)\s+([^ \t]+)\s+([^ \t]+)| (LL|UL|BS)\s+([^ \t]+)") From 65a5191ab87fda627727898abe9f8f67f13a849b Mon Sep 17 00:00:00 2001 From: Fabian Hofmann Date: Wed, 11 Mar 2026 14:29:39 +0100 Subject: [PATCH 4/8] Fix Xpress IIS mapping for masked constraints (#605) * Strengthen masked IIS regression test * Fix Xpress IIS mapping for masked constraints * Fix typing in masked IIS regression test --- doc/release_notes.rst | 6 ++++ linopy/model.py | 69 +++++++++++++++++++++++++++++--------- linopy/variables.py | 25 ++++++++++++++ test/test_infeasibility.py | 57 +++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 15 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index ec9ae84f..92cbb97a 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -1,6 +1,12 @@ Release Notes ============= +Version 0.6.7 +------------- + +* Fix Xpress IIS label mapping for masked constraints and add a regression test for matching infeasible coordinates. + + Version 0.6.6 ------------- diff --git a/linopy/model.py b/linopy/model.py index 871945ba..3797f8a9 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -222,12 +222,20 @@ def objective(self) -> Objective: @objective.setter def objective( self, obj: Objective | LinearExpression | QuadraticExpression - ) -> Objective: + ) -> None: + """ + Set the objective function. + + Parameters + ---------- + obj : Objective, LinearExpression, or QuadraticExpression + The objective to assign to the model. If not an Objective instance, + it will be wrapped in an Objective. + """ if not isinstance(obj, Objective): obj = Objective(obj, self) self._objective = obj - return self._objective @property def sense(self) -> str: @@ -238,6 +246,9 @@ def sense(self) -> str: @sense.setter def sense(self, value: str) -> None: + """ + Set the sense of the objective function. + """ self.objective.sense = value @property @@ -252,6 +263,9 @@ def parameters(self) -> Dataset: @parameters.setter def parameters(self, value: Dataset | Mapping) -> None: + """ + Set the parameters of the model. + """ self._parameters = Dataset(value) @property @@ -277,6 +291,9 @@ def status(self) -> str: @status.setter def status(self, value: str) -> None: + """ + Set the status of the model. + """ self._status = ModelStatus[value].value @property @@ -288,11 +305,13 @@ def termination_condition(self) -> str: @termination_condition.setter def termination_condition(self, value: str) -> None: - # TODO: remove if-clause, only kept for backward compatibility - if value: - self._termination_condition = TerminationCondition[value].value - else: + """ + Set the termination condition of the model. + """ + if value == "": self._termination_condition = value + else: + self._termination_condition = TerminationCondition[value].value @property def chunk(self) -> T_Chunks: @@ -303,6 +322,9 @@ def chunk(self) -> T_Chunks: @chunk.setter def chunk(self, value: T_Chunks) -> None: + """ + Set the chunk sizes of the model. + """ self._chunk = value @property @@ -320,6 +342,9 @@ def force_dim_names(self) -> bool: @force_dim_names.setter def force_dim_names(self, value: bool) -> None: + """ + Set whether to force custom dimension names for variables and constraints. + """ self._force_dim_names = bool(value) @property @@ -332,6 +357,9 @@ def auto_mask(self) -> bool: @auto_mask.setter def auto_mask(self, value: bool) -> None: + """ + Set whether to automatically mask variables and constraints with NaN values. + """ self._auto_mask = bool(value) @property @@ -343,6 +371,9 @@ def solver_dir(self) -> Path: @solver_dir.setter def solver_dir(self, value: str | Path) -> None: + """ + Set the solver directory of the model. + """ if not isinstance(value, str | Path): raise TypeError("'solver_dir' must path-like.") self._solver_dir = Path(value) @@ -1560,7 +1591,14 @@ def _compute_infeasibilities_gurobi(self, solver_model: Any) -> list[int]: return labels def _compute_infeasibilities_xpress(self, solver_model: Any) -> list[int]: - """Compute infeasibilities for Xpress solver.""" + """ + Compute infeasibilities for Xpress solver. + + This function correctly maps solver constraint positions to linopy + constraint labels, handling masked constraints where some labels may + be skipped (e.g., labels [0, 2, 4] with gaps instead of sequential + [0, 1, 2]). + """ # Compute all IIS try: # Try new API first solver_model.IISAll() @@ -1574,20 +1612,21 @@ def _compute_infeasibilities_xpress(self, solver_model: Any) -> list[int]: labels = set() - # Create constraint mapping for efficient lookups - constraint_to_index = { - constraint: idx - for idx, constraint in enumerate(solver_model.getConstraint()) - } + clabels = self.matrices.clabels + constraint_position_map = {} + for position, constraint_obj in enumerate(solver_model.getConstraint()): + if 0 <= position < len(clabels): + constraint_label = clabels[position] + if constraint_label >= 0: + constraint_position_map[constraint_obj] = constraint_label # Retrieve each IIS for iis_num in range(1, num_iis + 1): iis_constraints = self._extract_iis_constraints(solver_model, iis_num) - # Convert constraint objects to indices for constraint_obj in iis_constraints: - if constraint_obj in constraint_to_index: - labels.add(constraint_to_index[constraint_obj]) + if constraint_obj in constraint_position_map: + labels.add(constraint_position_map[constraint_obj]) # Note: Silently skip constraints not found in mapping # This can happen if the model structure changed after solving diff --git a/linopy/variables.py b/linopy/variables.py index d90a4775..07d29246 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -291,9 +291,15 @@ def at(self) -> AtIndexer: @property def loc(self) -> LocIndexer: + """ + Indexing the variable using coordinates. + """ return LocIndexer(self) def to_pandas(self) -> pd.Series: + """ + Convert the variable labels to a pandas Series. + """ return self.labels.to_pandas() def to_linexpr( @@ -734,10 +740,16 @@ def type(self) -> str: @property def coord_dims(self) -> tuple[Hashable, ...]: + """ + Get the coordinate dimensions of the variable. + """ return tuple(k for k in self.dims if k not in HELPER_DIMS) @property def coord_sizes(self) -> dict[Hashable, int]: + """ + Get the coordinate sizes of the variable. + """ return {k: v for k, v in self.sizes.items() if k not in HELPER_DIMS} @property @@ -1111,6 +1123,19 @@ def sanitize(self) -> Variable: return self def equals(self, other: Variable) -> bool: + """ + Check if this Variable is equal to another. + + Parameters + ---------- + other : Variable + The Variable to compare with. + + Returns + ------- + bool + True if the variables have equal labels, False otherwise. + """ return self.labels.equals(other.labels) # Wrapped function which would convert variable to dataarray diff --git a/test/test_infeasibility.py b/test/test_infeasibility.py index 01994789..74a63d6b 100644 --- a/test/test_infeasibility.py +++ b/test/test_infeasibility.py @@ -3,6 +3,8 @@ Test infeasibility detection for different solvers. """ +from typing import cast + import pandas as pd import pytest @@ -242,3 +244,58 @@ def test_deprecated_method( # Check that it contains constraint labels assert len(subset) > 0 + + @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + def test_masked_constraint_infeasibility( + self, solver: str, capsys: pytest.CaptureFixture[str] + ) -> None: + """ + Test infeasibility detection with masked constraints. + + This test verifies that the solver correctly maps constraint positions + to constraint labels when constraints are masked (some rows skipped). + 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: + pytest.skip(f"{solver} not available") + + m = Model() + + time = pd.RangeIndex(8, name="time") + x = m.add_variables(lower=0, upper=5, coords=[time], name="x") + y = m.add_variables(lower=0, upper=5, coords=[time], name="y") + + # Create a mask that keeps only even time indices (0, 2, 4, 6) + mask = pd.Series([i % 2 == 0 for i in range(len(time))]) + m.add_constraints(x + y >= 10, name="sum_lower", mask=mask) + + mask = pd.Series([False] * (len(time) // 2) + [True] * (len(time) // 2)) + m.add_constraints(x <= 4, name="x_upper", mask=mask) + + m.add_objective(x.sum() + y.sum()) + status, condition = m.solve(solver_name=solver) + + assert status == "warning" + assert "infeasible" in condition + + labels = m.compute_infeasibilities() + assert labels + + positions = [ + cast(tuple[str, dict[str, int]], m.constraints.get_label_position(label)) + for label in labels + ] + grouped_coords: dict[str, set[int]] = {"sum_lower": set(), "x_upper": set()} + for name, coord in positions: + assert name in grouped_coords + grouped_coords[name].add(coord["time"]) + + assert grouped_coords["sum_lower"] + assert grouped_coords["sum_lower"] == grouped_coords["x_upper"] + + m.print_infeasibilities() + output = capsys.readouterr().out + for time_coord in grouped_coords["sum_lower"]: + assert f"sum_lower[{time_coord}]" in output + assert f"x_upper[{time_coord}]" in output From ccecab66d982cf3078670652df415b8532c71954 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Fri, 17 Apr 2026 15:32:00 +0200 Subject: [PATCH 5/8] fix: use xarray.Dataset copy instead of constructor (#647) * fix: use xarray.Dataset copy instead of constructor since the latest xarray version, passing a Dataset as `data_vars` to the Dataset constructor is not supported. transpose and assign_coords already returns a new dataset. * Revert "fix: temporarily constrain xarray" This reverts commit 545b5636e12b992f61cd9f2f3ff605065c9cc596. --------- Co-authored-by: Jonas Hoersch --- doc/release_notes.rst | 1 + linopy/expressions.py | 7 +++---- linopy/model.py | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 92cbb97a..82e10995 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -5,6 +5,7 @@ Version 0.6.7 ------------- * Fix Xpress IIS label mapping for masked constraints and add a regression test for matching infeasible coordinates. +* Use ``xarray.Dataset.copy`` instead of constructor for compatibility with the latest xarray version. Version 0.6.6 diff --git a/linopy/expressions.py b/linopy/expressions.py index 649989f7..b7153d34 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -273,7 +273,7 @@ def sum(self, use_fallback: bool = False, **kwargs: Any) -> LinearExpression: index.names = [str(col) for col in orig_group.columns] index.name = GROUP_DIM new_coords = Coordinates.from_pandas_multiindex(index, GROUP_DIM) - ds = xr.Dataset(ds.assign_coords(new_coords)) + ds = ds.assign_coords(new_coords) ds = ds.rename({GROUP_DIM: final_group_name}) return LinearExpression(ds, self.model) @@ -380,8 +380,7 @@ def __init__(self, data: Dataset | Any | None, model: Model) -> None: coeffs_vars_dict = {str(k): v for k, v in coeffs_vars.items()} data = assign_multiindex_safe(data, **coeffs_vars_dict) - # transpose with new Dataset to really ensure correct order - data = Dataset(data.transpose(..., TERM_DIM)) + data = data.transpose(..., TERM_DIM) # ensure helper dimensions are not set as coordinates if drop_dims := set(HELPER_DIMS).intersection(data.coords): @@ -1806,7 +1805,7 @@ def __init__(self, data: Dataset | None, model: Model) -> None: raise ValueError(f"Size of dimension {FACTOR_DIM} must be 2.") # transpose data to have _term as last dimension and _factor as second last - data = xr.Dataset(data.transpose(..., FACTOR_DIM, TERM_DIM)) + data = data.transpose(..., FACTOR_DIM, TERM_DIM) self._data = data @property diff --git a/linopy/model.py b/linopy/model.py index 3797f8a9..594b77cf 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -266,7 +266,9 @@ def parameters(self, value: Dataset | Mapping) -> None: """ Set the parameters of the model. """ - self._parameters = Dataset(value) + self._parameters = ( + value.copy() if isinstance(value, Dataset) else Dataset(value) + ) @property def solution(self) -> Dataset: From 0bd33587530a7f7e3e5b1378a1c95677a7a522d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20H=C3=B6rsch?= Date: Fri, 17 Apr 2026 14:19:39 +0100 Subject: [PATCH 6/8] fix: blacklist highs 1.14.0 (#654) * fix: blacklist highs 1.14.0, relax python constraints * Update release_notes.rst * fix: temporarily constrain xarray * fix: fix types in expressions.merge * fix: more types problems * fix: rely on issubclass --- doc/release_notes.rst | 1 + pyproject.toml | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 82e10995..4339914a 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -6,6 +6,7 @@ Version 0.6.7 * Fix Xpress IIS label mapping for masked constraints and add a regression test for matching infeasible coordinates. * Use ``xarray.Dataset.copy`` instead of constructor for compatibility with the latest xarray version. +* Blacklist highspy 1.14.0 which produces wrong results due to broken presolve and crashes on Windows (`HiGHS#2964 `_). Version 0.6.6 diff --git a/pyproject.toml b/pyproject.toml index 0f5bd326..1b75a240 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "bottleneck", "toolz", "numexpr", - "xarray>=2024.2.0", + "xarray>=2024.2.0,<2026.4", "dask>=0.18.0", "polars>=1.31.1", "tqdm", @@ -76,13 +76,13 @@ dev = [ ] solvers = [ "gurobipy", - "highspy>=1.5.0; python_version < '3.12'", - "highspy>=1.7.1; python_version >= '3.12'", - "cplex; platform_system != 'Darwin' and python_version < '3.12'", + "highspy>=1.5.0,!=1.14.0; python_version < '3.12'", + "highspy>=1.7.1,!=1.14.0; python_version >= '3.12'", + "cplex; platform_system != 'Darwin'", "mosek", - "mindoptpy; python_version < '3.12'", + "mindoptpy", "coptpy!=7.2.1", - "xpress; platform_system != 'Darwin' and python_version < '3.11'", + "xpress; platform_system != 'Darwin'", "pyscipopt; platform_system != 'Darwin'", "knitro>=15.1.0", # "cupdlpx>=0.1.2", pip package currently unstable From 89af0e7c663f537bb7f5a19be5e9192f4b5a033d Mon Sep 17 00:00:00 2001 From: Fabian Hofmann Date: Tue, 21 Apr 2026 08:05:56 +0200 Subject: [PATCH 7/8] fix: compute a single IIS in Xpress infeasibility path (#658) --- doc/release_notes.rst | 1 + linopy/model.py | 29 +++++++++++------------------ 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 4339914a..e38b642f 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -5,6 +5,7 @@ Version 0.6.7 ------------- * Fix Xpress IIS label mapping for masked constraints and add a regression test for matching infeasible coordinates. +* Fix ``Model.compute_infeasibilities`` returning a flattened, deduplicated union of all IIS when Xpress found more than one. The Xpress path now computes a single IIS (via ``firstIIS``), matching the Gurobi path. * Use ``xarray.Dataset.copy`` instead of constructor for compatibility with the latest xarray version. * Blacklist highspy 1.14.0 which produces wrong results due to broken presolve and crashes on Windows (`HiGHS#2964 `_). diff --git a/linopy/model.py b/linopy/model.py index 594b77cf..473188c1 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1601,19 +1601,17 @@ def _compute_infeasibilities_xpress(self, solver_model: Any) -> list[int]: be skipped (e.g., labels [0, 2, 4] with gaps instead of sequential [0, 1, 2]). """ - # Compute all IIS + # Compute a single IIS (matches Gurobi behavior; multiple IIS would + # otherwise get flattened into an ambiguous union). Mode 2 prioritises + # a fast IIS search over minimality. try: # Try new API first - solver_model.IISAll() + solver_model.firstIIS(2) except AttributeError: # Fallback to old API - solver_model.iisall() + solver_model.iisfirst(2) - # Get the number of IIS found - num_iis = solver_model.attributes.numiis - if num_iis == 0: + if solver_model.attributes.numiis == 0: return [] - labels = set() - clabels = self.matrices.clabels constraint_position_map = {} for position, constraint_obj in enumerate(solver_model.getConstraint()): @@ -1622,17 +1620,12 @@ def _compute_infeasibilities_xpress(self, solver_model: Any) -> list[int]: if constraint_label >= 0: constraint_position_map[constraint_obj] = constraint_label - # Retrieve each IIS - for iis_num in range(1, num_iis + 1): - iis_constraints = self._extract_iis_constraints(solver_model, iis_num) - - for constraint_obj in iis_constraints: - if constraint_obj in constraint_position_map: - labels.add(constraint_position_map[constraint_obj]) - # Note: Silently skip constraints not found in mapping - # This can happen if the model structure changed after solving + labels = set() + for constraint_obj in self._extract_iis_constraints(solver_model, 1): + if constraint_obj in constraint_position_map: + labels.add(constraint_position_map[constraint_obj]) - return sorted(list(labels)) + return sorted(labels) def _extract_iis_constraints(self, solver_model: Any, iis_num: int) -> list[Any]: """ From 4fc1d6a71351d26ec91eb9ec0a84c4d889cdee85 Mon Sep 17 00:00:00 2001 From: Fabian Hofmann Date: Tue, 21 Apr 2026 09:06:17 +0200 Subject: [PATCH 8/8] Update pyproject.toml Co-authored-by: Felix <117816358+FBumann@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1b75a240..ba662aa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "bottleneck", "toolz", "numexpr", - "xarray>=2024.2.0,<2026.4", + "xarray>=2024.2.0", "dask>=0.18.0", "polars>=1.31.1", "tqdm",