Skip to content
Closed
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
22 changes: 20 additions & 2 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
Release Notes
=============

Upcoming Version
----------------
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 <https://github.com/ERGO-Code/HiGHS/issues/2964>`_).


Version 0.6.6
-------------

* Free the knitro context and compute necessary quantities within linopy. Knitro context is not exposed anymore.


Version 0.6.5
-------------

* Expose the knitro context to allow for more flexible use of the knitro python API.


Version 0.6.4
--------------
Expand Down
7 changes: 3 additions & 4 deletions linopy/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
98 changes: 66 additions & 32 deletions linopy/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -252,7 +263,12 @@ def parameters(self) -> Dataset:

@parameters.setter
def parameters(self, value: Dataset | Mapping) -> None:
self._parameters = Dataset(value)
"""
Set the parameters of the model.
"""
self._parameters = (
value.copy() if isinstance(value, Dataset) else Dataset(value)
)

@property
def solution(self) -> Dataset:
Expand All @@ -277,6 +293,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
Expand All @@ -288,11 +307,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:
Expand All @@ -303,6 +324,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
Expand All @@ -320,6 +344,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
Expand All @@ -332,6 +359,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
Expand All @@ -343,6 +373,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)
Expand Down Expand Up @@ -1560,38 +1593,39 @@ 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 all IIS
"""
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 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()

# 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])
# 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]:
"""
Expand Down
63 changes: 59 additions & 4 deletions linopy/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1745,7 +1745,10 @@ def get_solver_solution() -> Solution:
return Result(status, solution, m)


KnitroResult = namedtuple("KnitroResult", "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]):
Expand Down Expand Up @@ -1808,7 +1811,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)

Expand Down Expand Up @@ -1887,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]
Expand Down Expand Up @@ -1931,9 +1970,25 @@ 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(
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:
with contextlib.suppress(Exception):
knitro.KN_free(kc)
Expand Down
25 changes: 25 additions & 0 deletions linopy/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading