Skip to content
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ Highs.log
paper/
monkeytype.sqlite3

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

benchmark/*.pdf
benchmark/benchmarks
Expand Down
6 changes: 4 additions & 2 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
Release Notes
=============

.. Upcoming Version
.. ----------------
Upcoming Version
----------------

* The internal handling of `Solution` objects was improved for more consistency. Solution objects created from solver calls now preserve the exact index names from the input file.

Version 0.4.4
--------------
Expand Down
10 changes: 10 additions & 0 deletions linopy/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@
from linopy.variables import Variable


def set_int_index(series: pd.Series) -> pd.Series:
"""
Convert string index to int index.
"""
if not series.empty and not series.index.is_integer():
cutoff = count_initial_letters(str(series.index[0]))
series.index = series.index.str[cutoff:].astype(int)
return series


def maybe_replace_sign(sign: str) -> str:
"""
Replace the sign with an alternative sign if available.
Expand Down
3 changes: 3 additions & 0 deletions linopy/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
best_int,
maybe_replace_signs,
replace_by_map,
set_int_index,
to_path,
)
from linopy.constants import (
Expand Down Expand Up @@ -1189,6 +1190,7 @@ def solve(

# map solution and dual to original shape which includes missing values
sol = result.solution.primal.copy()
sol = set_int_index(sol)
sol.loc[-1] = nan

for name, var in self.variables.items():
Expand All @@ -1201,6 +1203,7 @@ def solve(

if not result.solution.dual.empty:
dual = result.solution.dual.copy()
dual = set_int_index(dual)
dual.loc[-1] = nan

for name, con in self.constraints.items():
Expand Down
45 changes: 5 additions & 40 deletions linopy/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@

import numpy as np
import pandas as pd
from pandas.core.series import Series

from linopy.common import count_initial_letters
from linopy.constants import (
Result,
Solution,
Expand Down Expand Up @@ -135,16 +133,6 @@
)


def set_int_index(series: Series) -> Series:
"""
Convert string index to int index.
"""
if not series.empty:
cutoff = count_initial_letters(str(series.index[0]))
series.index = series.index.str[cutoff:].astype(int)
return series


# using enum to match solver subclasses with names
class SolverName(enum.Enum):
CBC = "cbc"
Expand Down Expand Up @@ -466,8 +454,8 @@ def get_solver_solution():
)
variables_b = df.index.str[0] == "x"

sol = df[variables_b][2].pipe(set_int_index)
dual = df[~variables_b][3].pipe(set_int_index)
sol = df[variables_b][2]
dual = df[~variables_b][3]
return Solution(sol, dual, objective)

solution = self.safe_get_solution(status=status, func=get_solver_solution)
Expand Down Expand Up @@ -624,11 +612,7 @@ def get_solver_solution() -> Solution:
dual_io = io.StringIO("".join(read_until_break(f))[:-2])
dual_ = pd.read_fwf(dual_io)[1:].set_index("Row name")
if "Marginal" in dual_:
dual = (
pd.to_numeric(dual_["Marginal"], "coerce")
.fillna(0)
.pipe(set_int_index)
)
dual = pd.to_numeric(dual_["Marginal"], "coerce").fillna(0)
else:
logger.warning("Dual values of MILP couldn't be parsed")
dual = pd.Series(dtype=float)
Expand All @@ -638,7 +622,6 @@ def get_solver_solution() -> Solution:
pd.read_fwf(sol_io)[1:]
.set_index("Column name")["Activity"]
.astype(float)
.pipe(set_int_index)
)
f.close()
return Solution(sol, dual, objective)
Expand Down Expand Up @@ -860,12 +843,8 @@ def get_solver_solution() -> Solution:
sol = pd.Series(solution.col_value, model.matrices.vlabels, dtype=float)
dual = pd.Series(solution.row_dual, model.matrices.clabels, dtype=float)
else:
sol = pd.Series(
solution.col_value, h.getLp().col_names_, dtype=float
).pipe(set_int_index)
dual = pd.Series(
solution.row_dual, h.getLp().row_names_, dtype=float
).pipe(set_int_index)
sol = pd.Series(solution.col_value, h.getLp().col_names_, dtype=float)
dual = pd.Series(solution.row_dual, h.getLp().row_names_, dtype=float)

return Solution(sol, dual, objective)

Expand Down Expand Up @@ -1084,13 +1063,11 @@ def get_solver_solution() -> Solution:
objective = m.ObjVal

sol = pd.Series({v.VarName: v.x for v in m.getVars()}, dtype=float)
sol = set_int_index(sol)

try:
dual = pd.Series(
{c.ConstrName: c.Pi for c in m.getConstrs()}, dtype=float
)
dual = set_int_index(dual)
except AttributeError:
logger.warning("Dual values of MILP couldn't be parsed")
dual = pd.Series(dtype=float)
Expand Down Expand Up @@ -1229,15 +1206,13 @@ def get_solver_solution() -> Solution:
solution = pd.Series(
m.solution.get_values(), m.variables.get_names(), dtype=float
)
solution = set_int_index(solution)

if is_lp:
dual = pd.Series(
m.solution.get_dual_values(),
m.linear_constraints.get_names(),
dtype=float,
)
dual = set_int_index(dual)
else:
logger.warning("Dual values of MILP couldn't be parsed")
dual = pd.Series(dtype=float)
Expand Down Expand Up @@ -1366,15 +1341,13 @@ def get_solver_solution() -> Solution:
sol.drop(
["quadobjvar", "qmatrixvar"], errors="ignore", inplace=True, axis=0
)
sol = set_int_index(sol)

cons = m.getConss()
if len(cons) != 0:
dual = pd.Series({c.name: m.getDualSolVal(c) for c in cons})
dual = dual[
dual.index.str.startswith("c") & ~dual.index.str.startswith("cf")
]
dual = set_int_index(dual)
else:
logger.warning("Dual values of MILP couldn't be parsed")
dual = pd.Series(dtype=float)
Expand Down Expand Up @@ -1504,12 +1477,10 @@ def get_solver_solution() -> Solution:
var = [str(v) for v in m.getVariable()]

sol = pd.Series(m.getSolution(var), index=var, dtype=float)
sol = set_int_index(sol)

try:
dual_ = [str(d) for d in m.getConstraint()]
dual = pd.Series(m.getDual(dual_), index=dual_, dtype=float)
dual = set_int_index(dual)
except (xpress.SolverError, xpress.ModelError, SystemError):
logger.warning("Dual values of MILP couldn't be parsed")
dual = pd.Series(dtype=float)
Expand Down Expand Up @@ -1833,13 +1804,11 @@ def get_solver_solution() -> Solution:
sol = m.getxx(soltype)
sol = {m.getvarname(i): sol[i] for i in range(m.getnumvar())}
sol = pd.Series(sol, dtype=float)
sol = set_int_index(sol)

try:
dual = m.gety(soltype)
dual = {m.getconname(i): dual[i] for i in range(m.getnumcon())}
dual = pd.Series(dual, dtype=float)
dual = set_int_index(dual)
except (mosek.Error, AttributeError):
logger.warning("Dual values of MILP couldn't be parsed")
dual = pd.Series(dtype=float)
Expand Down Expand Up @@ -1975,11 +1944,9 @@ def get_solver_solution() -> Solution:
objective = m.BestObj if m.ismip else m.LpObjVal

sol = pd.Series({v.name: v.x for v in m.getVars()}, dtype=float)
sol = set_int_index(sol)

try:
dual = pd.Series({v.name: v.pi for v in m.getConstrs()}, dtype=float)
dual = set_int_index(dual)
except (coptpy.CoptError, AttributeError):
logger.warning("Dual values of MILP couldn't be parsed")
dual = pd.Series(dtype=float)
Expand Down Expand Up @@ -2119,11 +2086,9 @@ def get_solver_solution() -> Solution:
objective = m.objval

sol = pd.Series({v.varname: v.X for v in m.getVars()}, dtype=float)
sol = set_int_index(sol)

try:
dual = pd.Series({c.constrname: c.DualSoln for c in m.getConstrs()})
dual = set_int_index(dual)
except (mindoptpy.MindoptError, AttributeError):
logger.warning("Dual values of MILP couldn't be parsed")
dual = pd.Series(dtype=float)
Expand Down
2 changes: 1 addition & 1 deletion linopy/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@
print_coord,
print_single_variable,
save_join,
set_int_index,
to_dataframe,
to_polars,
)
from linopy.config import options
from linopy.constants import HELPER_DIMS, TERM_DIM
from linopy.solvers import set_int_index
from linopy.types import NotImplementedType

if TYPE_CHECKING:
Expand Down
64 changes: 64 additions & 0 deletions test/test_solvers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""
Created on Tue Jan 28 09:03:35 2025.

@author: sid
"""

import pytest

from linopy import solvers

free_mps_problem = """NAME sample_mip
ROWS
N obj
G c1
L c2
E c3
COLUMNS
col1 obj 5
col1 c1 2
col1 c2 4
col1 c3 1
MARK0000 'MARKER' 'INTORG'
colu2 obj 3
colu2 c1 3
colu2 c2 2
colu2 c3 1
col3 obj 7
col3 c1 4
col3 c2 3
col3 c3 1
MARK0001 'MARKER' 'INTEND'
RHS
RHS_V c1 12
RHS_V c2 15
RHS_V c3 6
BOUNDS
UP BOUND col1 4
UI BOUND colu2 3
UI BOUND col3 5
ENDATA
"""


@pytest.mark.parametrize("solver", set(solvers.available_solvers))
def test_free_mps_solution_parsing(solver, tmp_path):
try:
solver_enum = solvers.SolverName(solver.lower())
solver_class = getattr(solvers, solver_enum.name)
except ValueError:
raise ValueError(f"Solver '{solver}' is not recognized")

# Write the MPS file to the temporary directory
mps_file = tmp_path / "problem.mps"
mps_file.write_text(free_mps_problem)

# Create a solution file path in the temporary directory
sol_file = tmp_path / "solution.sol"

s = solver_class()
result = s.solve_problem(problem_fn=mps_file, solution_fn=sol_file)

assert result.status.is_ok
assert result.solution.objective == 30.0
Loading