From fc01f130262798200df8cda80330c6220ca96dcc Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 12 May 2026 06:24:02 +0200 Subject: [PATCH 01/27] feat: add SolverReport and extend Result with solver_name --- linopy/constants.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/linopy/constants.py b/linopy/constants.py index 5cc98ce2..9f449e01 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -276,6 +276,18 @@ class Solution: objective: float = field(default=np.nan) +@dataclass +class SolverReport: + """ + Solver-reported performance metrics. + """ + + runtime: float | None = None + mip_gap: float | None = None + barrier_iterations: int | None = None + simplex_iterations: int | None = None + + @dataclass class Result: """ @@ -285,6 +297,8 @@ class Result: status: Status solution: Solution | None = None solver_model: Any = None + solver_name: str = "" + report: SolverReport | None = None def __repr__(self) -> str: solver_model_string = ( @@ -297,10 +311,21 @@ def __repr__(self) -> str: ) else: solution_string = "Solution: None\n" + solver_name_string = ( + f"Solver: {self.solver_name}\n" if self.solver_name else "" + ) + report_string = "" + if self.report is not None: + if self.report.runtime is not None: + report_string += f"Runtime: {self.report.runtime:.2f}s\n" + if self.report.mip_gap is not None: + report_string += f"MIP gap: {self.report.mip_gap:.2e}\n" return ( f"Status: {self.status.status.value}\n" f"Termination condition: {self.status.termination_condition.value}\n" + solution_string + + solver_name_string + + report_string + f"Solver model: {solver_model_string}\n" f"Solver message: {self.status.legacy_status}" ) From 7a2b5f62ea076d87849b8f839eaa19020cf3aba4 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 12 May 2026 06:32:26 +0200 Subject: [PATCH 02/27] refactor: solver state on instance + Result wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B of solver refactor (issue #628). Makes the Solver instance the canonical owner of solver-side state. - Base Solver.__init__ now initializes options, status, solution, report, solver_model, io_api, env, capability, _env_stack. - Adds to_solver_model / update_solver_model / resolve / close / __del__ on the base class; resolve dispatches to per-subclass _resolve. - Adds _make_result helper that populates instance state and stamps solver_name and report onto Result. - Gurobi: env creation moved off per-call ExitStack onto self._env_stack so the env remains valid after solve returns; to_solver_model and _resolve overrides wired. - Highs / Mosek / cuPDLPx: to_solver_model + _resolve overrides; Mosek task is now kept alive via self._env_stack instead of being closed at function exit. - CBC / GLPK / Cplex / SCIP / Xpress / Knitro / COPT / MindOpt: minimal wiring — populate self.status/self.solution/self.solver_model/self.io_api via _make_result and pass solver_name + report (where readily available) into the returned Result. solve_problem dispatcher and the public solve_problem_from_model / solve_problem_from_file signatures are unchanged. Model.solve is untouched (Phase C). --- linopy/solvers.py | 504 ++++++++++++++++++++++++++++++---------------- 1 file changed, 326 insertions(+), 178 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 86c312e4..0d32aaaf 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -29,12 +29,15 @@ from linopy.constants import ( Result, Solution, + SolverReport, SolverStatus, Status, TerminationCondition, ) from linopy.solver_capabilities import ( + SOLVER_REGISTRY, SolverFeature, + SolverInfo, get_solvers_with_feature, ) @@ -310,13 +313,68 @@ def __init__( self, **solver_options: Any, ) -> None: - self.solver_options = solver_options + self.options: dict[str, Any] = solver_options + self.solver_options: dict[str, Any] = solver_options + self.status: Status | None = None + self.solution: Solution | None = None + self.report: SolverReport | None = None + self.solver_model: Any = None + self.io_api: str | None = None + self.env: Any = None + self.capability: SolverInfo | None = SOLVER_REGISTRY.get( + self.solver_name.value + ) + self._env_stack: contextlib.ExitStack | None = None - # Check for the solver to be initialized whether the package is installed or not. if self.solver_name.value not in available_solvers: msg = f"Solver package for '{self.solver_name.value}' is not installed. Please install first to initialize solver instance." raise ImportError(msg) + def to_solver_model(self, model: Model, **kwargs: Any) -> Any: + raise NotImplementedError + + def update_solver_model(self, model: Model, **kwargs: Any) -> None: + raise NotImplementedError + + def resolve(self, sense: str) -> Result: + if self.solver_model is None: + raise RuntimeError("call to_solver_model first") + return self._resolve(sense) + + def _resolve(self, sense: str) -> Result: + raise NotImplementedError + + def close(self) -> None: + if self._env_stack is not None: + self._env_stack.close() + self.env = None + self.solver_model = None + self._env_stack = None + + def __del__(self) -> None: + with contextlib.suppress(Exception): + self.close() + + def _make_result( + self, + status: Status, + solution: Solution | None, + solver_model: Any = None, + report: SolverReport | None = None, + ) -> Result: + self.status = status + self.solution = solution + self.report = report + if solver_model is not None: + self.solver_model = solver_model + return Result( + status=status, + solution=solution, + solver_model=solver_model, + solver_name=self.solver_name.value, + report=report, + ) + def safe_get_solution( self, status: Status, func: Callable[[], Solution] ) -> Solution: @@ -609,7 +667,13 @@ def get_solver_solution() -> Solution: runtime = float(m.group(1)) CbcModel = namedtuple("CbcModel", ["mip_gap", "runtime"]) - return Result(status, solution, CbcModel(mip_gap, runtime)) + self.io_api = io_api + return self._make_result( + status, + solution, + solver_model=CbcModel(mip_gap, runtime), + report=SolverReport(runtime=runtime, mip_gap=mip_gap), + ) class GLPK(Solver[None]): @@ -740,7 +804,8 @@ def solve_problem_from_file( if not os.path.exists(solution_fn): status = Status(SolverStatus.warning, TerminationCondition.unknown) - return Result(status, Solution()) + self.io_api = io_api + return self._make_result(status, Solution()) f = open(solution_fn) @@ -780,7 +845,8 @@ def get_solver_solution() -> Solution: solution = self.safe_get_solution(status=status, func=get_solver_solution) solution = maybe_adjust_objective_sign(solution, io_api, sense) - return Result(status, solution) + self.io_api = io_api + return self._make_result(status, solution) class Highs(Solver[None]): @@ -809,48 +875,14 @@ def __init__( ) -> None: super().__init__(**solver_options) - def solve_problem_from_model( + def to_solver_model( self, model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, explicit_coordinate_names: bool = False, set_names: bool = True, - ) -> Result: - """ - Solve a linear problem directly from a linopy model using the HiGHS solver. - Reads a linear problem file and passes it to the HiGHS solver. - If the solution is feasible the function returns the - objective, solution and dual constraint variables. - - Parameters - ---------- - model : linopy.model - Linopy model for the problem. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - Environment for the solver - explicit_coordinate_names : bool, optional - Transfer variable and constraint names to the solver (default: False) - set_names : bool, optional - Whether to set variable and constraint names (default: True). - Setting to False can significantly speed up model export. - - Returns - ------- - Result - """ - # check for HiGHS solver compatibility + log_fn: Path | None = None, + **kwargs: Any, + ) -> highspy.Highs: if self.solver_options.get("solver") in [ "simplex", "ipm", @@ -869,9 +901,30 @@ def solve_problem_from_model( set_names=set_names, ) self._set_solver_params(h, log_fn) + self.solver_model = h + self.io_api = "direct" + return h + + def solve_problem_from_model( + self, + model: Model, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: None = None, + explicit_coordinate_names: bool = False, + set_names: bool = True, + ) -> Result: + self.to_solver_model( + model, + explicit_coordinate_names=explicit_coordinate_names, + set_names=set_names, + log_fn=log_fn, + ) return self._solve( - h, + self.solver_model, solution_fn, warmstart_fn, basis_fn, @@ -880,6 +933,9 @@ def solve_problem_from_model( sense=model.sense, ) + def _resolve(self, sense: str) -> Result: + return self._solve(self.solver_model, io_api=self.io_api, sense=sense) + def solve_problem_from_file( self, problem_fn: Path, @@ -920,13 +976,15 @@ def solve_problem_from_file( self._set_solver_params(h, log_fn) h.readModel(problem_fn_) + self.solver_model = h + self.io_api = read_io_api_from_problem_file(problem_fn) return self._solve( h, solution_fn, warmstart_fn, basis_fn, - io_api=read_io_api_from_problem_file(problem_fn), + io_api=self.io_api, sense=read_sense_from_problem_file(problem_fn), ) @@ -1038,7 +1096,20 @@ def get_solver_solution() -> Solution: solution = self.safe_get_solution(status=status, func=get_solver_solution) solution = maybe_adjust_objective_sign(solution, io_api, sense) - return Result(status, solution, h) + runtime: float | None = None + mip_gap: float | None = None + with contextlib.suppress(Exception): + runtime = float(h.getRunTime()) + with contextlib.suppress(Exception): + mip_gap = float(h.getInfo().mip_gap) + + self.io_api = io_api + return self._make_result( + status, + solution, + solver_model=h, + report=SolverReport(runtime=runtime, mip_gap=mip_gap), + ) class Gurobi(Solver["gurobipy.Env | dict[str, Any] | None"]): @@ -1057,6 +1128,38 @@ def __init__( ) -> None: super().__init__(**solver_options) + def _resolve_env( + self, env: gurobipy.Env | dict[str, Any] | None + ) -> gurobipy.Env: + self.close() + self._env_stack = contextlib.ExitStack() + if env is None: + resolved = self._env_stack.enter_context(gurobipy.Env()) + elif isinstance(env, dict): + resolved = self._env_stack.enter_context(gurobipy.Env(params=env)) + else: + resolved = env + self.env = resolved + return resolved + + def to_solver_model( + self, + model: Model, + explicit_coordinate_names: bool = False, + env: gurobipy.Env | dict[str, Any] | None = None, + set_names: bool = True, + **kwargs: Any, + ) -> gurobipy.Model: + env_ = self._resolve_env(env) + m = model.to_gurobipy( + env=env_, + explicit_coordinate_names=explicit_coordinate_names, + set_names=set_names, + ) + self.solver_model = m + self.io_api = "direct" + return m + def solve_problem_from_model( self, model: Model, @@ -1068,58 +1171,32 @@ def solve_problem_from_model( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> Result: - """ - Solve a linear problem directly from a linopy model using the Gurobi solver. - Reads a problem file and passes it to the Gurobi solver. - This function communicates with gurobi using the gurobipy package. - - Parameters - ---------- - model : linopy.model - Linopy model for the problem. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : gurobipy.Env or dict, optional - Gurobi environment for the solver, pass env directly or kwargs for creation. - explicit_coordinate_names : bool, optional - Transfer variable and constraint names to the solver (default: False) - set_names : bool, optional - Whether to set variable and constraint names (default: True). - Setting to False can significantly speed up model export. - - Returns - ------- - Result - """ - with contextlib.ExitStack() as stack: - if env is None: - env_ = stack.enter_context(gurobipy.Env()) - elif isinstance(env, dict): - env_ = stack.enter_context(gurobipy.Env(params=env)) - else: - env_ = env - - m = model.to_gurobipy( - env=env_, - explicit_coordinate_names=explicit_coordinate_names, - set_names=set_names, - ) + self.to_solver_model( + model, + explicit_coordinate_names=explicit_coordinate_names, + env=env, + set_names=set_names, + ) + return self._solve( + self.solver_model, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + io_api="direct", + sense=model.sense, + ) - return self._solve( - m, - solution_fn=solution_fn, - log_fn=log_fn, - warmstart_fn=warmstart_fn, - basis_fn=basis_fn, - io_api="direct", - sense=model.sense, - ) + def _resolve(self, sense: str) -> Result: + return self._solve( + self.solver_model, + solution_fn=None, + log_fn=None, + warmstart_fn=None, + basis_fn=None, + io_api=self.io_api, + sense=sense, + ) def solve_problem_from_file( self, @@ -1158,25 +1235,20 @@ def solve_problem_from_file( io_api = read_io_api_from_problem_file(problem_fn) problem_fn_ = path_to_string(problem_fn) - with contextlib.ExitStack() as stack: - if env is None: - env_ = stack.enter_context(gurobipy.Env()) - elif isinstance(env, dict): - env_ = stack.enter_context(gurobipy.Env(params=env)) - else: - env_ = env + env_ = self._resolve_env(env) + m = gurobipy.read(problem_fn_, env=env_) + self.solver_model = m + self.io_api = io_api - m = gurobipy.read(problem_fn_, env=env_) - - return self._solve( - m, - solution_fn=solution_fn, - log_fn=log_fn, - warmstart_fn=warmstart_fn, - basis_fn=basis_fn, - io_api=io_api, - sense=sense, - ) + return self._solve( + m, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + io_api=io_api, + sense=sense, + ) def _solve( self, @@ -1279,7 +1351,20 @@ def get_solver_solution() -> Solution: solution = self.safe_get_solution(status=status, func=get_solver_solution) solution = maybe_adjust_objective_sign(solution, io_api, sense) - return Result(status, solution, m) + runtime: float | None = None + mip_gap: float | None = None + with contextlib.suppress(Exception): + runtime = float(m.Runtime) + with contextlib.suppress(Exception): + mip_gap = float(m.MIPGap) + + self.io_api = io_api + return self._make_result( + status, + solution, + solver_model=m, + report=SolverReport(runtime=runtime, mip_gap=mip_gap), + ) class Cplex(Solver[None]): @@ -1438,7 +1523,8 @@ def get_solver_solution() -> Solution: solution = self.safe_get_solution(status=status, func=get_solver_solution) solution = maybe_adjust_objective_sign(solution, io_api, sense) - return Result(status, solution, m) + self.io_api = io_api + return self._make_result(status, solution, solver_model=m) class SCIP(Solver[None]): @@ -1592,7 +1678,8 @@ def get_solver_solution() -> Solution: solution = self.safe_get_solution(status=status, func=get_solver_solution) solution = maybe_adjust_objective_sign(solution, io_api, sense) - return Result(status, solution, m) + self.io_api = io_api + return self._make_result(status, solution, solver_model=m) class Xpress(Solver[None]): @@ -1766,7 +1853,8 @@ def get_solver_solution() -> Solution: solution = self.safe_get_solution(status=status, func=get_solver_solution) solution = maybe_adjust_objective_sign(solution, io_api, sense) - return Result(status, solution, m) + self.io_api = io_api + return self._make_result(status, solution, solver_model=m) KnitroResult = namedtuple( @@ -1994,24 +2082,29 @@ def get_solver_solution() -> Solution: solution_fn.parent.mkdir(exist_ok=True) knitro.KN_write_mps_file(kc, path_to_string(solution_fn)) - return Result( + knitro_model = 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, + ) + self.io_api = io_api + return self._make_result( 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, + solver_model=knitro_model, + report=SolverReport( + runtime=reported_runtime, mip_gap=mip_rel_gap ), ) finally: @@ -2096,22 +2189,50 @@ def solve_problem_from_model( DeprecationWarning, stacklevel=2, ) - with mosek.Task() as m: - m = model.to_mosek( - m, - explicit_coordinate_names=explicit_coordinate_names, - set_names=set_names, - ) + self.to_solver_model( + model, + explicit_coordinate_names=explicit_coordinate_names, + set_names=set_names, + ) + return self._solve( + self.solver_model, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + io_api="direct", + sense=model.sense, + ) - return self._solve( - m, - solution_fn=solution_fn, - log_fn=log_fn, - warmstart_fn=warmstart_fn, - basis_fn=basis_fn, - io_api="direct", - sense=model.sense, - ) + def _resolve(self, sense: str) -> Result: + return self._solve( + self.solver_model, + solution_fn=None, + log_fn=None, + warmstart_fn=None, + basis_fn=None, + io_api=self.io_api, + sense=sense, + ) + + def to_solver_model( + self, + model: Model, + explicit_coordinate_names: bool = False, + set_names: bool = True, + **kwargs: Any, + ) -> mosek.Task: + self.close() + self._env_stack = contextlib.ExitStack() + task = self._env_stack.enter_context(mosek.Task()) + m = model.to_mosek( + task, + explicit_coordinate_names=explicit_coordinate_names, + set_names=set_names, + ) + self.solver_model = m + self.io_api = "direct" + return m def solve_problem_from_file( self, @@ -2154,23 +2275,25 @@ def solve_problem_from_file( DeprecationWarning, stacklevel=2, ) - with mosek.Task() as m: - # read sense and io_api from problem file - sense = read_sense_from_problem_file(problem_fn) - io_api = read_io_api_from_problem_file(problem_fn) - # for Mosek solver, the path needs to be a string - problem_fn_ = path_to_string(problem_fn) - m.readdata(problem_fn_) - - return self._solve( - m, - solution_fn=solution_fn, - log_fn=log_fn, - warmstart_fn=warmstart_fn, - basis_fn=basis_fn, - io_api=io_api, - sense=sense, - ) + self.close() + self._env_stack = contextlib.ExitStack() + m = self._env_stack.enter_context(mosek.Task()) + sense = read_sense_from_problem_file(problem_fn) + io_api = read_io_api_from_problem_file(problem_fn) + problem_fn_ = path_to_string(problem_fn) + m.readdata(problem_fn_) + self.solver_model = m + self.io_api = io_api + + return self._solve( + m, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + io_api=io_api, + sense=sense, + ) def _solve( self, @@ -2366,7 +2489,8 @@ def get_solver_solution() -> Solution: solution = self.safe_get_solution(status=status, func=get_solver_solution) solution = maybe_adjust_objective_sign(solution, io_api, sense) - return Result(status, solution) + self.io_api = io_api + return self._make_result(status, solution, solver_model=m) class COPT(Solver[None]): @@ -2508,7 +2632,8 @@ def get_solver_solution() -> Solution: env_.close() - return Result(status, solution, m) + self.io_api = io_api + return self._make_result(status, solution, solver_model=m) class MindOpt(Solver[None]): @@ -2653,7 +2778,8 @@ def get_solver_solution() -> Solution: m.dispose() env_.dispose() - return Result(status, solution, m) + self.io_api = io_api + return self._make_result(status, solution, solver_model=m) class PIPS(Solver[None]): @@ -2792,14 +2918,10 @@ def solve_problem_from_model( Result """ - if model.type in ["QP", "MILP"]: - msg = "cuPDLPx does not currently support QP or MILP problems." - raise NotImplementedError(msg) - - cu_model = model.to_cupdlpx() + self.to_solver_model(model) return self._solve( - cu_model, + self.solver_model, l_model=model, solution_fn=solution_fn, log_fn=log_fn, @@ -2809,6 +2931,23 @@ def solve_problem_from_model( sense=model.sense, ) + def to_solver_model(self, model: Model, **kwargs: Any) -> cupdlpx.Model: + if model.type in ["QP", "MILP"]: + msg = "cuPDLPx does not currently support QP or MILP problems." + raise NotImplementedError(msg) + cu_model = model.to_cupdlpx() + self.solver_model = cu_model + self.io_api = "direct" + return cu_model + + def _resolve(self, sense: str) -> Result: + return self._solve( + self.solver_model, + l_model=None, + io_api=self.io_api, + sense=sense, + ) + def _solve( self, cu_model: cupdlpx.Model, @@ -2904,8 +3043,17 @@ def get_solver_solution() -> Solution: solution = self.safe_get_solution(status=status, func=get_solver_solution) solution = maybe_adjust_objective_sign(solution, io_api, sense) - # see https://github.com/MIT-Lu-Lab/cuPDLPx/tree/main/python#solution-attributes - return Result(status, solution, cu_model) + runtime: float | None = None + with contextlib.suppress(Exception): + runtime = float(cu_model.Runtime) + + self.io_api = io_api + return self._make_result( + status, + solution, + solver_model=cu_model, + report=SolverReport(runtime=runtime), + ) def _set_solver_params(self, cu_model: cupdlpx.Model) -> None: """ From 31ac03dcecf5745347b50320cdb9bd04ee2dc86f Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 12 May 2026 06:43:02 +0200 Subject: [PATCH 03/27] refactor: introduce Model.apply_result and model.solver --- linopy/model.py | 119 ++++++++++++++++++++++++++------------------ linopy/variables.py | 2 +- 2 files changed, 72 insertions(+), 49 deletions(-) diff --git a/linopy/model.py b/linopy/model.py index 21e4e29c..e8d3ad78 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -48,6 +48,7 @@ SOS_TYPE_ATTR, TERM_DIM, ModelStatus, + Result, TerminationCondition, ) from linopy.constraints import ( @@ -193,8 +194,7 @@ class Model: the optimization process. """ - solver_model: Any - solver_name: str + solver: solvers.Solver | None _variables: Variables _constraints: Constraints _objective: Objective @@ -241,8 +241,7 @@ class Model: "_solver_dir", "_relaxed_registry", "_piecewise_formulations", - "solver_model", - "solver_name", + "solver", "__weakref__", ) @@ -312,6 +311,15 @@ def __init__( self._solver_dir: Path = Path( gettempdir() if solver_dir is None else solver_dir ) + self.solver: solvers.Solver | None = None + + @property + def solver_model(self) -> Any: + return self.solver.solver_model if self.solver is not None else None + + @property + def solver_name(self) -> str | None: + return self.solver.solver_name.value if self.solver is not None else None @property def matrices(self) -> MatrixAccessor: @@ -1707,15 +1715,15 @@ def solve( try: solver_class = getattr(solvers, f"{solvers.SolverName(solver_name).name}") - # initialize the solver as object of solver subclass - solver = solver_class( - **solver_options, - ) + if self.solver is not None: + self.solver.close() + solver = solver_class(**solver_options) + self.solver = solver + if io_api == "direct": if set_names is None: set_names = self.set_names_in_solver_io - # no problem file written and direct model is set for solver - result = solver.solve_problem_from_model( + solver.solve_problem_from_model( model=self, solution_fn=to_path(solution_fn), log_fn=to_path(log_fn), @@ -1741,7 +1749,7 @@ def solve( slice_size=slice_size, progress=progress, ) - result = solver.solve_problem_from_file( + solver.solve_problem_from_file( problem_fn=to_path(problem_fn), solution_fn=to_path(solution_fn), log_fn=to_path(log_fn), @@ -1756,48 +1764,65 @@ def solve( os.remove(fn) try: - result.info() + return self.apply_result() + finally: + if sos_reform_result is not None: + undo_sos_reformulation(self, sos_reform_result) - self.objective._value = result.solution.objective - self.status = result.status.status.value - self.termination_condition = result.status.termination_condition.value - self.solver_model = result.solver_model - self.solver_name = solver_name - - if not result.status.is_ok: - return ( - result.status.status.value, - result.status.termination_condition.value, + def apply_result(self, result: Result | None = None) -> tuple[str, str]: + if result is None: + if self.solver is None or self.solver.status is None: + raise RuntimeError( + "No solver state available; call solve() first or pass a Result." ) + result = Result( + status=self.solver.status, + solution=self.solver.solution, + solver_model=self.solver.solver_model, + solver_name=self.solver.solver_name.value, + report=self.solver.report, + ) - # 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 + result.info() - sol_arr = series_to_lookup_array(sol) + if result.solution is not None: + self.objective._value = result.solution.objective - for _, var in self.variables.items(): - vals = lookup_vals(sol_arr, np.ravel(var.labels)) - var.solution = xr.DataArray(vals.reshape(var.labels.shape), var.coords) + status_value = result.status.status.value + termination_condition = result.status.termination_condition.value + self.status = status_value + self.termination_condition = termination_condition - if not result.solution.dual.empty: - dual = result.solution.dual.copy() - dual = set_int_index(dual) - dual.loc[-1] = nan + if not result.status.is_ok: + return status_value, termination_condition - dual_arr = series_to_lookup_array(dual) + if result.solution is None or len(result.solution.primal) == 0: + return status_value, termination_condition - for _, con in self.constraints.items(): - vals = lookup_vals(dual_arr, np.ravel(con.labels)) - con.dual = xr.DataArray( - vals.reshape(con.labels.shape), con.labels.coords - ) + sol = result.solution.primal.copy() + sol = set_int_index(sol) + sol.loc[-1] = nan - return result.status.status.value, result.status.termination_condition.value - finally: - if sos_reform_result is not None: - undo_sos_reformulation(self, sos_reform_result) + sol_arr = series_to_lookup_array(sol) + + for _, var in self.variables.items(): + vals = lookup_vals(sol_arr, np.ravel(var.labels)) + var.solution = xr.DataArray(vals.reshape(var.labels.shape), var.coords) + + if not result.solution.dual.empty: + dual = result.solution.dual.copy() + dual = set_int_index(dual) + dual.loc[-1] = nan + + dual_arr = series_to_lookup_array(dual) + + for _, con in self.constraints.items(): + vals = lookup_vals(dual_arr, np.ravel(con.labels)) + con.dual = xr.DataArray( + vals.reshape(con.labels.shape), con.labels.coords + ) + + return status_value, termination_condition def _mock_solve( self, @@ -1819,8 +1844,6 @@ def _mock_solve( self.objective._value = 0.0 self.status = "ok" self.termination_condition = TerminationCondition.optimal.value - self.solver_model = None - self.solver_name = solver_name for name, var in self.variables.items(): var.solution = xr.DataArray(0.0, var.coords) @@ -1843,7 +1866,7 @@ def compute_infeasibilities(self) -> list[int]: labels : list[int] Labels of the infeasible constraints. """ - solver_model = getattr(self, "solver_model", None) + solver_model = self.solver_model # Check for Gurobi if "gurobi" in available_solvers: @@ -1872,7 +1895,7 @@ def compute_infeasibilities(self) -> list[int]: # If we get here, either the solver doesn't support IIS or no solver model is available if solver_model is None: # Check if this is a supported solver without a stored model - solver_name = getattr(self, "solver_name", "unknown") + solver_name = self.solver_name or "unknown" if solver_supports(solver_name, SolverFeature.IIS_COMPUTATION): raise ValueError( "No solver model available. The model must be solved first with " diff --git a/linopy/variables.py b/linopy/variables.py index dfc49a8f..b6a74f29 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -979,7 +979,7 @@ def get_solver_attribute(self, attr: str) -> DataArray: """ solver_model = self.model.solver_model if not solver_supports( - self.model.solver_name, SolverFeature.SOLVER_ATTRIBUTE_ACCESS + self.model.solver_name or "", SolverFeature.SOLVER_ATTRIBUTE_ACCESS ): raise NotImplementedError( "Solver attribute getter only supports the Gurobi solver for now." From 2120cbb03a9783459c89008a304cd3a5366b6591 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 12 May 2026 06:49:02 +0200 Subject: [PATCH 04/27] test: update test_no_solver_model_error for new solver attribute --- test/test_infeasibility.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/test_infeasibility.py b/test/test_infeasibility.py index feb7782d..78073acf 100644 --- a/test/test_infeasibility.py +++ b/test/test_infeasibility.py @@ -166,9 +166,7 @@ def test_no_solver_model_error(self, solver: str) -> None: # Solve the model first m.solve(solver_name=solver) - # Manually remove the solver_model to simulate cleanup - m.solver_model = None - m.solver_name = solver # But keep the solver name + m.solver.solver_model = None # Should raise ValueError since we know it was solved with supported solver with pytest.raises(ValueError, match="No solver model available"): From f392a23e15b363435918694f8a17a9b5068e0726 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 12 May 2026 06:54:49 +0200 Subject: [PATCH 05/27] test: cover Solver instance persistence and apply_result paths --- test/test_solvers.py | 102 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/test/test_solvers.py b/test/test_solvers.py index 7f4d55ec..e4ffdb85 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -7,12 +7,112 @@ from pathlib import Path +import numpy as np +import pandas as pd import pytest from test_io import model # noqa: F401 -from linopy import Model, solvers +from linopy import GREATER_EQUAL, Model, solvers +from linopy.constants import Result, Solution, Status from linopy.solver_capabilities import SolverFeature, solver_supports + +@pytest.fixture +def simple_model() -> Model: + m = Model(chunk=None) + x = m.add_variables(name="x") + y = m.add_variables(name="y") + m.add_constraints(2 * x + 6 * y, GREATER_EQUAL, 10) + m.add_constraints(4 * x + 2 * y, GREATER_EQUAL, 3) + m.add_objective(2 * y + x) + return m + + +@pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) +def test_solver_instance_attached_after_solve( + simple_model: Model, solver: str +) -> None: + simple_model.solve(solver) + assert isinstance(simple_model.solver, solvers.Solver) + assert simple_model.solver.status is not None + assert simple_model.solver.status.is_ok + assert simple_model.solver.solution is not None + assert simple_model.solver_model is simple_model.solver.solver_model + assert simple_model.solver_name == solver + + +@pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) +def test_result_carries_solver_name(simple_model: Model, solver: str) -> None: + solver_enum = solvers.SolverName(solver.lower()) + solver_class = getattr(solvers, solver_enum.name) + instance = solver_class() + result = instance.solve_problem(model=simple_model) + assert result.solver_name == solver + + +@pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) +def test_to_solver_model_then_resolve(simple_model: Model, solver: str) -> None: + if not solver_supports(solver, SolverFeature.DIRECT_API): + pytest.skip("Solver does not support direct API.") + solver_enum = solvers.SolverName(solver.lower()) + solver_class = getattr(solvers, solver_enum.name) + instance = solver_class() + instance.to_solver_model(simple_model) + result = instance.resolve(simple_model.sense) + + reference = Model(chunk=None) + rx = reference.add_variables(name="x") + ry = reference.add_variables(name="y") + reference.add_constraints(2 * rx + 6 * ry, GREATER_EQUAL, 10) + reference.add_constraints(4 * rx + 2 * ry, GREATER_EQUAL, 3) + reference.add_objective(2 * ry + rx) + reference.solve(solver, io_api="direct") + + assert result.status.is_ok + assert result.solution is not None + assert np.isclose(result.solution.objective, reference.objective.value) + + +def test_apply_result_explicit(simple_model: Model) -> None: + x_labels = simple_model.variables["x"].labels.values + y_labels = simple_model.variables["y"].labels.values + primal = pd.Series( + {int(x_labels): 1.5, int(y_labels): 2.0}, dtype=float + ) + solution = Solution(primal=primal, objective=5.5) + result = Result( + status=Status.from_termination_condition("optimal"), + solution=solution, + solver_name="mock", + ) + simple_model.solver = None + simple_model.apply_result(result) + assert simple_model.status == "ok" + assert simple_model.termination_condition == "optimal" + assert simple_model.objective.value == 5.5 + assert float(simple_model.variables["x"].solution) == 1.5 + assert float(simple_model.variables["y"].solution) == 2.0 + + +@pytest.mark.skipif( + "gurobi" not in set(solvers.available_solvers), reason="Gurobi is not installed" +) +def test_gurobi_env_persists_after_solve(simple_model: Model) -> None: + simple_model.solve("gurobi", io_api="direct") + assert simple_model.solver is not None + assert simple_model.solver.env is not None + assert isinstance(simple_model.solver_model.NumVars, int) + + +@pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) +def test_solver_close_releases_state(simple_model: Model, solver: str) -> None: + simple_model.solve(solver) + solver_instance = simple_model.solver + assert solver_instance is not None + solver_instance.close() + assert solver_instance.solver_model is None + assert solver_instance.env is None + free_mps_problem = """NAME sample_mip ROWS N obj From 859be09cfa752ad9aaf26226f5b0b543d07bd411 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 12 May 2026 08:49:37 +0200 Subject: [PATCH 06/27] feat: add __repr__ to Solver class Surfaces solver name, status, io_api, and solution/report summary. --- linopy/solvers.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/linopy/solvers.py b/linopy/solvers.py index 0d32aaaf..f75b42a1 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -355,6 +355,21 @@ def __del__(self) -> None: with contextlib.suppress(Exception): self.close() + def __repr__(self) -> str: + status = self.status.status.value if self.status is not None else "unsolved" + parts = [f"name={self.solver_name.value!r}", f"status={status!r}"] + if self.io_api is not None: + parts.append(f"io_api={self.io_api!r}") + if self.solver_model is not None: + parts.append("solver_model=loaded") + if self.env is not None: + parts.append("env=active") + if self.solution is not None: + parts.append(f"objective={self.solution.objective:.4g}") + if self.report is not None and self.report.runtime is not None: + parts.append(f"runtime={self.report.runtime:.3g}s") + return f"{type(self).__name__}({', '.join(parts)})" + def _make_result( self, status: Status, From da878019b3ba027702f0283a715771b358a2e42a Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 12 May 2026 12:29:56 +0200 Subject: [PATCH 07/27] refactor: declare solver capabilities on Solver subclasses Move SolverFeature and _xpress_supports_gpu into linopy.solvers; declare features/display_name as ClassVars on each Solver subclass with a Solver.supports() classmethod. solver_capabilities becomes a back-compat shim with a lazy SOLVER_REGISTRY mapping. Model.solve uses the class API directly; SolverFeature is re-exported at the package top level. --- ...cewise-feasibility-tests-walkthrough.ipynb | 974 +++++++++++++----- linopy/__init__.py | 2 + linopy/model.py | 23 +- linopy/solver_capabilities.py | 319 +----- linopy/solvers.py | 215 +++- linopy/variables.py | 3 +- pyproject.toml | 1 + test/test_solvers.py | 60 +- 8 files changed, 1073 insertions(+), 524 deletions(-) diff --git a/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb b/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb index b2fdaf7c..e985c8d8 100644 --- a/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb +++ b/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb @@ -2,347 +2,859 @@ "cells": [ { "cell_type": "markdown", + "id": "ab725da0", "metadata": {}, "source": [ - "# `test_piecewise_feasibility.py` — visual walkthrough\n", + "# Solver capability migration — walkthrough\n", "\n", - "**Purpose:** document what each test class in `test/test_piecewise_feasibility.py` actually probes, with pictures. Intended as review aid for the PR — **not** merged into master.\n", + "**Branch:** `solver-refac` \n", + "**Goal:** capability data lives on each `Solver` subclass as `ClassVar`s. `linopy.solver_capabilities` becomes a back-compat shim.\n", "\n", - "The test file stress-tests the claim that `add_piecewise_formulation(sign=\"<=\"/\">=\")` yields the **same feasible region** for `(x, y)` regardless of which method (`lp` / `sos2` / `incremental`) dispatches the formulation, on curves where all three are applicable.\n", + "What used to be duplicated between `solvers.py` (the classes) and `solver_capabilities.py` (`SOLVER_REGISTRY`) is now declared once, on the class itself. The registry is a lazy view over the classes.\n", "\n", - "Four test classes:\n", + "Sections:\n", + "1. Class-level API: `Solver.features`, `Solver.supports`, `Solver.display_name`\n", + "2. Back-compat shim — every legacy import still works\n", + "3. `SOLVER_REGISTRY` is now a lazy `Mapping` view\n", + "4. Xpress GPU feature is still version-gated\n", + "5. Internal callers (`Model.solve`) use the class API directly\n", "\n", - "| class | what it probes | scope |\n", - "|---|---|---|\n", - "| `TestRotatedObjective` | support-function equivalence — 16 rotation directions | the strong test |\n", - "| `TestDomainBoundary` | `x` outside `[x_min, x_max]` is infeasible | LP explicit vs SOS2 implicit |\n", - "| `TestPointwiseInfeasibility` | `y` just past `f(x)` is infeasible | targeted sanity check |\n", - "| `TestNVariableInequality` | 3-variable: first tuple bounded, rest equality | SOS2 vs incremental only |\n", - "\n", - "Below: one visualization per class.\n", + "*Run from the repo root.*" + ] + }, + { + "cell_type": "markdown", + "id": "d92f1020", + "metadata": {}, + "source": [ + "## 1. Class-level API\n", "\n", - "*Run this notebook from the repository root so that `from test.test_piecewise_feasibility import ...` resolves.*" + "Each `Solver` subclass declares `features` and `display_name` as `ClassVar`s, and inherits a `supports()` classmethod from the base class." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, + "id": "a8fd9c74", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-23T08:00:47.376525Z", - "start_time": "2026-04-23T08:00:46.142492Z" + "execution": { + "iopub.execute_input": "2026-05-12T09:01:38.713028Z", + "iopub.status.busy": "2026-05-12T09:01:38.712887Z", + "iopub.status.idle": "2026-05-12T09:01:39.324386Z", + "shell.execute_reply": "2026-05-12T09:01:39.324012Z" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "[,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ]" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", + "from linopy import SolverFeature # newly re-exported at the top level\n", + "from linopy import solvers\n", "\n", - "from test.test_piecewise_feasibility import (\n", - " CURVES,\n", - " Y_HI,\n", - " Y_LO,\n", - " Curve,\n", - ")" + "list(SolverFeature)" ] }, { - "cell_type": "markdown", - "metadata": {}, + "cell_type": "code", + "execution_count": 2, + "id": "b7756f8e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-12T09:01:39.326242Z", + "iopub.status.busy": "2026-05-12T09:01:39.326013Z", + "iopub.status.idle": "2026-05-12T09:01:39.340797Z", + "shell.execute_reply": "2026-05-12T09:01:39.340336Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gurobi\n", + "True\n", + "False\n", + "True\n" + ] + } + ], "source": [ - "## Shared primitive: draw the curve and its feasible region" + "print(solvers.Gurobi.display_name)\n", + "print(solvers.Gurobi.supports(SolverFeature.SOS_CONSTRAINTS))\n", + "print(solvers.Highs.supports(SolverFeature.SOS_CONSTRAINTS))\n", + "print(solvers.cuPDLPx.supports(SolverFeature.GPU_ACCELERATION))" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, + "id": "0403ecce", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-23T08:00:47.384959Z", - "start_time": "2026-04-23T08:00:47.381361Z" + "execution": { + "iopub.execute_input": "2026-05-12T09:01:39.342087Z", + "iopub.status.busy": "2026-05-12T09:01:39.341872Z", + "iopub.status.idle": "2026-05-12T09:01:39.372020Z", + "shell.execute_reply": "2026-05-12T09:01:39.371614Z" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
INTEGER_VARIABLESQUADRATIC_OBJECTIVEDIRECT_APILP_FILE_NAMESREAD_MODEL_FROM_FILESOLUTION_FILE_NOT_NEEDEDGPU_ACCELERATIONIIS_COMPUTATIONSOS_CONSTRAINTSSEMI_CONTINUOUS_VARIABLESSOLVER_ATTRIBUTE_ACCESS
solver
cbcTrueFalseFalseFalseTrueFalseFalseFalseFalseFalseFalse
glpkTrueFalseFalseFalseTrueFalseFalseFalseFalseFalseFalse
highsTrueTrueTrueTrueTrueTrueFalseFalseFalseTrueFalse
cplexTrueTrueFalseTrueTrueFalseFalseFalseTrueTrueFalse
gurobiTrueTrueTrueTrueTrueTrueFalseTrueTrueTrueTrue
scipTrueTrueFalseTrueTrueTrueFalseFalseFalseFalseFalse
xpressTrueTrueFalseTrueTrueTrueFalseTrueFalseFalseFalse
knitroTrueTrueFalseTrueTrueTrueFalseFalseFalseFalseFalse
mosekTrueTrueTrueTrueTrueTrueFalseFalseFalseFalseFalse
coptTrueTrueFalseTrueTrueTrueFalseFalseFalseFalseFalse
mindoptTrueTrueFalseTrueTrueTrueFalseFalseFalseFalseFalse
pipsFalseFalseFalseFalseFalseFalseFalseFalseFalseFalseFalse
cupdlpxFalseFalseTrueFalseFalseTrueTrueFalseFalseFalseFalse
\n", + "
" + ], + "text/plain": [ + " INTEGER_VARIABLES QUADRATIC_OBJECTIVE DIRECT_API LP_FILE_NAMES \\\n", + "solver \n", + "cbc True False False False \n", + "glpk True False False False \n", + "highs True True True True \n", + "cplex True True False True \n", + "gurobi True True True True \n", + "scip True True False True \n", + "xpress True True False True \n", + "knitro True True False True \n", + "mosek True True True True \n", + "copt True True False True \n", + "mindopt True True False True \n", + "pips False False False False \n", + "cupdlpx False False True False \n", + "\n", + " READ_MODEL_FROM_FILE SOLUTION_FILE_NOT_NEEDED GPU_ACCELERATION \\\n", + "solver \n", + "cbc True False False \n", + "glpk True False False \n", + "highs True True False \n", + "cplex True False False \n", + "gurobi True True False \n", + "scip True True False \n", + "xpress True True False \n", + "knitro True True False \n", + "mosek True True False \n", + "copt True True False \n", + "mindopt True True False \n", + "pips False False False \n", + "cupdlpx False True True \n", + "\n", + " IIS_COMPUTATION SOS_CONSTRAINTS SEMI_CONTINUOUS_VARIABLES \\\n", + "solver \n", + "cbc False False False \n", + "glpk False False False \n", + "highs False False True \n", + "cplex False True True \n", + "gurobi True True True \n", + "scip False False False \n", + "xpress True False False \n", + "knitro False False False \n", + "mosek False False False \n", + "copt False False False \n", + "mindopt False False False \n", + "pips False False False \n", + "cupdlpx False False False \n", + "\n", + " SOLVER_ATTRIBUTE_ACCESS \n", + "solver \n", + "cbc False \n", + "glpk False \n", + "highs False \n", + "cplex False \n", + "gurobi True \n", + "scip False \n", + "xpress False \n", + "knitro False \n", + "mosek False \n", + "copt False \n", + "mindopt False \n", + "pips False \n", + "cupdlpx False " + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "def draw_curve_and_region(ax, curve: Curve, *, shade: bool = True) -> None:\n", - " \"\"\"Plot breakpoints + shade the feasible region (hypograph or epigraph).\"\"\"\n", - " xs = np.array(curve.x_pts)\n", - " ys = np.array(curve.y_pts)\n", - " ax.plot(xs, ys, \"o-\", color=\"C0\", lw=2, label=\"breakpoints\")\n", - "\n", - " if shade:\n", - " if curve.sign == \"<=\":\n", - " ax.fill_between(\n", - " xs,\n", - " np.full_like(ys, Y_LO),\n", - " ys,\n", - " alpha=0.15,\n", - " color=\"C0\",\n", - " label=f\"feasible: y {curve.sign} f(x)\",\n", - " )\n", - " else:\n", - " ax.fill_between(\n", - " xs,\n", - " ys,\n", - " np.full_like(ys, Y_HI),\n", - " alpha=0.15,\n", - " color=\"C0\",\n", - " label=f\"feasible: y {curve.sign} f(x)\",\n", - " )\n", + "# A quick capability matrix across every Solver subclass.\n", + "import pandas as pd\n", "\n", - " pad_x = 0.15 * (xs.max() - xs.min())\n", - " pad_y = 0.15 * (ys.max() - ys.min()) + 1\n", - " ax.set_xlim(xs.min() - pad_x, xs.max() + pad_x)\n", - " ax.set_ylim(ys.min() - pad_y, ys.max() + pad_y)\n", - " ax.set_xlabel(\"x\")\n", - " ax.set_ylabel(\"y\")\n", - " ax.grid(alpha=0.3)" + "rows = []\n", + "for name in solvers.SolverName:\n", + " cls = getattr(solvers, name.name)\n", + " rows.append({\"solver\": name.value, **{f.name: cls.supports(f) for f in SolverFeature}})\n", + "pd.DataFrame(rows).set_index(\"solver\")" ] }, { "cell_type": "markdown", + "id": "e37d4778", "metadata": {}, "source": [ - "## `TestRotatedObjective` — the strong test\n", + "## 2. Back-compat shim\n", "\n", - "For every direction `(α, β)` on the unit circle, minimize `α·x + β·y` under the PWL. The answer is the **support function** of the feasible region in direction `(α, β)` — and for a convex region, the support function uniquely determines the region. If LP and SOS2/incremental give the same support-function value for 16 directions, their feasible regions are identical.\n", - "\n", - "Each red dot below is the extreme point the solver lands at for one direction. The arrows show the objective-push direction. A failure would manifest as one method's dot landing at a different vertex than the oracle's." + "Downstream packages (pypsa-eur, …) import from `linopy.solver_capabilities`. Every legacy symbol still resolves, but `solver_supports` / `get_solvers_with_feature` now delegate to the solver classes." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, + "id": "cd259d73", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-23T08:00:47.829483Z", - "start_time": "2026-04-23T08:00:47.388542Z" + "execution": { + "iopub.execute_input": "2026-05-12T09:01:39.373376Z", + "iopub.status.busy": "2026-05-12T09:01:39.373162Z", + "iopub.status.idle": "2026-05-12T09:01:39.385969Z", + "shell.execute_reply": "2026-05-12T09:01:39.385464Z" } }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\n", + "['highs', 'gurobi', 'mosek', 'cupdlpx']\n", + "['highs', 'gurobi']\n" + ] + } + ], "source": [ - "def panel_rotated_objective(ax, curve: Curve, n_dirs: int = 16) -> None:\n", - " draw_curve_and_region(ax, curve)\n", - " xs, ys = np.array(curve.x_pts), np.array(curve.y_pts)\n", - " cx = 0.5 * (xs.min() + xs.max())\n", - " cy = 0.5 * (ys.min() + ys.max())\n", - " arrow_len = 0.25 * min(xs.max() - xs.min(), (ys.max() - ys.min()) + 5)\n", - "\n", - " for i in range(n_dirs):\n", - " theta = 2 * np.pi * i / n_dirs\n", - " alpha, beta = np.cos(theta), np.sin(theta)\n", - " ax.annotate(\n", - " \"\",\n", - " xytext=(cx, cy),\n", - " xy=(cx + arrow_len * alpha, cy + arrow_len * beta),\n", - " arrowprops=dict(arrowstyle=\"->\", color=\"C3\", alpha=0.4, lw=1),\n", - " )\n", - " # Oracle extreme point in this direction\n", - " verts = curve.vertices()\n", - " extreme = min(verts, key=lambda v: alpha * v[0] + beta * v[1])\n", - " ax.plot(*extreme, \"o\", color=\"C3\", ms=4, alpha=0.7)\n", - "\n", - " ax.plot([], [], \"o\", color=\"C3\", alpha=0.7, label=f\"{n_dirs} extreme points\")\n", - " ax.legend(loc=\"upper left\", fontsize=8)\n", - " ax.set_title(f\"{curve.name} (sign={curve.sign})\")\n", + "from linopy.solver_capabilities import (\n", + " SOLVER_REGISTRY,\n", + " SolverFeature as SF_shim,\n", + " SolverInfo,\n", + " get_available_solvers_with_feature,\n", + " get_solvers_with_feature,\n", + " solver_supports,\n", + ")\n", "\n", + "# Same enum object — the shim re-exports from linopy.solvers.\n", + "assert SF_shim is SolverFeature\n", "\n", - "fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))\n", - "panel_rotated_objective(axes[0], CURVES[0]) # concave-smooth\n", - "panel_rotated_objective(axes[1], CURVES[2]) # convex-steep\n", - "panel_rotated_objective(axes[2], CURVES[5]) # two-segment\n", - "fig.suptitle(\n", - " \"TestRotatedObjective — support function sampled at 16 directions\", fontsize=12\n", - ")\n", - "plt.tight_layout();" + "print(solver_supports(\"gurobi\", SolverFeature.IIS_COMPUTATION))\n", + "print(get_solvers_with_feature(SolverFeature.DIRECT_API))\n", + "print(get_available_solvers_with_feature(SolverFeature.DIRECT_API, solvers.available_solvers))" ] }, { "cell_type": "markdown", + "id": "8cc6f939", "metadata": {}, "source": [ - "Notice the **dots cluster at the curve breakpoints** (top edges) and at the **bottom corners** `(x_min, Y_LO)`, `(x_max, Y_LO)`. That's because the feasible region is a polygon: linear objectives always attain their optimum at a vertex.\n", + "## 3. `SOLVER_REGISTRY` is a lazy Mapping\n", "\n", - "The 288 pytest items (6 curves × 3 methods × 16 directions) check that all three methods land at the same extreme point for every direction." + "Downstream code that does `for name in SOLVER_REGISTRY: info = SOLVER_REGISTRY[name]; ...` keeps working. `SolverInfo` is constructed on demand from the class declarations." ] }, { - "cell_type": "markdown", - "metadata": {}, + "cell_type": "code", + "execution_count": 5, + "id": "f44e3093", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-12T09:01:39.387637Z", + "iopub.status.busy": "2026-05-12T09:01:39.387412Z", + "iopub.status.idle": "2026-05-12T09:01:39.403181Z", + "shell.execute_reply": "2026-05-12T09:01:39.402742Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "_LazyRegistry\n", + "13 solvers known\n", + "SolverInfo\n", + "gurobi · Gurobi\n", + "['DIRECT_API', 'IIS_COMPUTATION', 'INTEGER_VARIABLES', 'LP_FILE_NAMES', 'QUADRATIC_OBJECTIVE', 'READ_MODEL_FROM_FILE', 'SEMI_CONTINUOUS_VARIABLES', 'SOLUTION_FILE_NOT_NEEDED', 'SOLVER_ATTRIBUTE_ACCESS', 'SOS_CONSTRAINTS']\n" + ] + } + ], "source": [ - "## `TestDomainBoundary` — enforce `x ∈ [x_min, x_max]`\n", - "\n", - "LP enforces this with an explicit constraint; SOS2/incremental enforce it implicitly via `sum(λ) = 1`. Two different implementations of the same bound — worth a direct probe." + "print(type(SOLVER_REGISTRY).__name__)\n", + "print(len(SOLVER_REGISTRY), \"solvers known\")\n", + "info = SOLVER_REGISTRY[\"gurobi\"]\n", + "print(type(info).__name__)\n", + "print(info.name, \"·\", info.display_name)\n", + "print(sorted(f.name for f in info.features))" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, + "id": "776d220b", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-23T08:00:48.103641Z", - "start_time": "2026-04-23T08:00:47.835275Z" + "execution": { + "iopub.execute_input": "2026-05-12T09:01:39.404450Z", + "iopub.status.busy": "2026-05-12T09:01:39.404244Z", + "iopub.status.idle": "2026-05-12T09:01:39.418918Z", + "shell.execute_reply": "2026-05-12T09:01:39.418492Z" } }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "shim and class API agree on all 143 pairs\n" + ] + } + ], "source": [ - "def panel_domain_boundary(ax, curve: Curve) -> None:\n", - " draw_curve_and_region(ax, curve)\n", - " xs = np.array(curve.x_pts)\n", - " y_span = ax.get_ylim()\n", - " ax.axvline(xs[0], color=\"C2\", lw=1.5, label=f\"x_min={xs[0]}\")\n", - " ax.axvline(xs[-1], color=\"C2\", lw=1.5, label=f\"x_max={xs[-1]}\")\n", - " ax.axvline(xs[0] - 1, color=\"C3\", lw=1.5, ls=\"--\")\n", - " ax.axvline(xs[-1] + 1, color=\"C3\", lw=1.5, ls=\"--\")\n", - " yy = y_span[1] - 0.12 * (y_span[1] - y_span[0])\n", - " ax.text(\n", - " xs[0] - 1, yy, \"INFEASIBLE\\n(x < x_min)\", ha=\"center\", fontsize=8, color=\"C3\"\n", - " )\n", - " ax.text(\n", - " xs[-1] + 1, yy, \"INFEASIBLE\\n(x > x_max)\", ha=\"center\", fontsize=8, color=\"C3\"\n", - " )\n", - " ax.legend(loc=\"lower center\", fontsize=7)\n", - " ax.set_title(f\"{curve.name} — domain probe\")\n", - "\n", - "\n", - "fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))\n", - "panel_domain_boundary(axes[0], CURVES[0]) # concave-smooth\n", - "panel_domain_boundary(axes[1], CURVES[1]) # concave-shifted (negative domain)\n", - "panel_domain_boundary(axes[2], CURVES[5]) # two-segment\n", - "fig.suptitle(\n", - " \"TestDomainBoundary — x outside the breakpoint range is infeasible\", fontsize=12\n", - ")\n", - "plt.tight_layout();" + "# Round-trip property: shim ↔ class API agree for every (solver, feature) pair.\n", + "mismatches = [\n", + " (n.value, f.name)\n", + " for n in solvers.SolverName\n", + " for f in SolverFeature\n", + " if solver_supports(n.value, f) != getattr(solvers, n.name).supports(f)\n", + "]\n", + "assert mismatches == [], mismatches\n", + "print(\"shim and class API agree on all\", len(list(solvers.SolverName)) * len(list(SolverFeature)), \"pairs\")" ] }, { "cell_type": "markdown", + "id": "47ee876b", "metadata": {}, "source": [ - "## `TestPointwiseInfeasibility` — y just past the curve\n", + "## 4. Xpress GPU is still version-gated\n", "\n", - "Rotated objectives probe *extremes*; this test specifically nudges `y` past `f(x)` by a small margin (`0.01`) and asserts infeasibility. Catches NaN-mask or off-by-one-segment bugs that might accidentally allow slack." + "`_xpress_supports_gpu()` is now defined in `linopy.solvers` and evaluated once at class-definition time — same semantics as before, just colocated with the class it gates." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, + "id": "4c74cd49", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-23T08:00:48.366674Z", - "start_time": "2026-04-23T08:00:48.112127Z" + "execution": { + "iopub.execute_input": "2026-05-12T09:01:39.420308Z", + "iopub.status.busy": "2026-05-12T09:01:39.420131Z", + "iopub.status.idle": "2026-05-12T09:01:39.436186Z", + "shell.execute_reply": "2026-05-12T09:01:39.435677Z" } }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "xpress >= 9.8.0 installed? False\n", + "Xpress.supports(GPU_ACCELERATION): False\n" + ] + } + ], "source": [ - "def panel_pointwise(ax, curve: Curve) -> None:\n", - " draw_curve_and_region(ax, curve)\n", - " xs = np.array(curve.x_pts)\n", - " x_mid = 0.5 * (xs[0] + xs[-1])\n", - " fx = curve.f(x_mid)\n", - " y_bad = fx + 0.01 if curve.sign == \"<=\" else fx - 0.01\n", - " ax.plot(x_mid, fx, \"o\", color=\"C2\", ms=9, label=f\"on curve: f({x_mid:g})={fx:g}\")\n", - " ax.plot(\n", - " x_mid, y_bad, \"x\", color=\"C3\", ms=14, mew=3, label=f\"infeasible: y={y_bad:g}\"\n", - " )\n", - " ax.legend(loc=\"lower right\", fontsize=7)\n", - " ax.set_title(f\"{curve.name} — nudge past f(x)\")\n", + "from linopy.solvers import _xpress_supports_gpu\n", "\n", - "\n", - "fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))\n", - "panel_pointwise(axes[0], CURVES[0]) # concave-smooth, sign=\"<=\"\n", - "panel_pointwise(axes[1], CURVES[2]) # convex-steep, sign=\">=\"\n", - "panel_pointwise(axes[2], CURVES[4]) # linear-gte\n", - "fig.suptitle(\n", - " \"TestPointwiseInfeasibility — y past the curve by 0.01 in the sign direction\",\n", - " fontsize=12,\n", - ")\n", - "plt.tight_layout();" + "gpu = _xpress_supports_gpu()\n", + "print(\"xpress >= 9.8.0 installed?\", gpu)\n", + "print(\"Xpress.supports(GPU_ACCELERATION):\", solvers.Xpress.supports(SolverFeature.GPU_ACCELERATION))\n", + "assert solvers.Xpress.supports(SolverFeature.GPU_ACCELERATION) == gpu" ] }, { "cell_type": "markdown", + "id": "6131eb92", "metadata": {}, "source": [ - "## `TestNVariableInequality` — 3-variable sign split\n", + "## 5. Internal callers use the class API\n", "\n", - "With three tuples `(fuel, power, heat)` and `sign=\"<=\"`:\n", - "- `fuel` (the **first** tuple) is **bounded above** by its interpolated value,\n", - "- `power` and `heat` (remaining tuples) are **forced to equality** — pinned on the curve.\n", + "`Model.solve()` resolves `solver_class` once and then asks `solver_class.supports(...)` for every pre-instantiation check (quadratic objective, SOS constraints, semi-continuous vars, LP file names, solution-file-not-needed). `compute_infeasibilities()` queries `self.solver.supports(IIS_COMPUTATION)` on the already-instantiated solver.\n", "\n", - "LP doesn't support N > 2 tuples, so this class compares SOS2 vs incremental only. The 3D plot shows the CHP curve and the 7 test points (one per `power_fix`) that both methods must agree on." + "The only remaining string-keyed call (`solver_supports(name, ...)`) lives in `linopy/variables.py`, where a constraint-add path runs before a solver instance exists. It routes through the shim — no behavior change." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, + "id": "1ac953f0", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-23T08:00:48.489668Z", - "start_time": "2026-04-23T08:00:48.371526Z" + "execution": { + "iopub.execute_input": "2026-05-12T09:01:39.437867Z", + "iopub.status.busy": "2026-05-12T09:01:39.437621Z", + "iopub.status.idle": "2026-05-12T09:01:39.732262Z", + "shell.execute_reply": "2026-05-12T09:01:39.731765Z" } }, - "outputs": [], - "source": [ - "bp = {\n", - " \"power\": np.array([0, 30, 60, 100]),\n", - " \"fuel\": np.array([0, 40, 85, 160]),\n", - " \"heat\": np.array([0, 25, 55, 95]),\n", - "}\n", - "\n", - "fig = plt.figure(figsize=(9, 6.5))\n", - "ax = fig.add_subplot(projection=\"3d\")\n", - "ax.plot(\n", - " bp[\"power\"], bp[\"fuel\"], bp[\"heat\"], \"o-\", color=\"C0\", lw=2, label=\"CHP breakpoints\"\n", - ")\n", - "\n", - "for p in [0, 15, 30, 45, 60, 80, 100]:\n", - " f = np.interp(p, bp[\"power\"], bp[\"fuel\"])\n", - " h = np.interp(p, bp[\"power\"], bp[\"heat\"])\n", - " ax.plot([p], [f], [h], \"o\", color=\"C3\", ms=7)\n", - " # drop to base plane\n", - " ax.plot([p, p], [f, 0], [h, h], color=\"C3\", alpha=0.3, lw=0.8)\n", - "\n", - "ax.set_xlabel(\"power\")\n", - "ax.set_ylabel(\"fuel\")\n", - "ax.set_zlabel(\"heat\")\n", - "ax.plot(\n", - " [],\n", - " [],\n", - " \"o\",\n", - " color=\"C3\",\n", - " label=\"7 test points — power pinned,\\nfuel at upper bound, heat on curve\",\n", - ")\n", - "ax.set_title('TestNVariableInequality — CHP curve (sign=\"<=\")')\n", - "ax.legend(loc=\"upper left\", fontsize=8);" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Restricted license - for non-production use only - expires 2027-11-29\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Read LP format model from file /tmp/linopy-problem-pi2lkhq7.lp\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Reading time = 0.00 seconds\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "obj: 2 rows, 2 columns, 4 nonzeros\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gurobi Optimizer version 13.0.2 build v13.0.2rc1 (linux64 - \"Ubuntu 24.04.4 LTS\")\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU model: Intel(R) Core(TM) Ultra 7 165U, instruction set [SSE2|AVX|AVX2]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Thread count: 14 physical cores, 14 logical processors, using up to 14 threads\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Optimize a model with 2 rows, 2 columns and 4 nonzeros (Min)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Model fingerprint: 0x364477a6\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Model has 2 linear objective coefficients\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Coefficient statistics:\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Matrix range [2e+00, 6e+00]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Objective range [1e+00, 2e+00]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Bounds range [0e+00, 0e+00]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " RHS range [3e+00, 1e+01]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Presolve removed 2 rows and 2 columns\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Presolve time: 0.00s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Presolve: All rows and columns removed\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Iteration Objective Primal Inf. Dual Inf. Time\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " 0 3.3000000e+00 0.000000e+00 0.000000e+00 0s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Solved in 0 iterations and 0.00 seconds (0.00 work units)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Optimal objective 3.300000000e+00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "solver: Gurobi(name='gurobi', status='ok', io_api='lp', solver_model=loaded, env=active, objective=3.3, runtime=0.00488s)\n", + "supports IIS? True\n" + ] + } + ], "source": [ - "## What a failing test would tell you\n", + "# Quick end-to-end smoke: solve uses the new path, status is OK.\n", + "from linopy import GREATER_EQUAL, Model\n", "\n", - "- **Rotated objective fails**: the methods disagree on the feasible region in some direction. The failure message includes the attained `(x, y)` point — you'd see which extreme point one method landed at that the others didn't.\n", - "- **Domain boundary fails**: one method lets `x` escape `[x_min, x_max]`. LP path most likely: the domain-bound constraint was dropped. SOS2 path: the `sum(λ) = 1` constraint was weakened.\n", - "- **Pointwise infeasibility fails**: one method accepts a point past the curve. Most often a NaN-mask bug in per-entity formulations, or a wrong segment getting picked.\n", - "- **N-variable fails**: the sign split went wrong — either an input leaked into the signed link or the first-tuple convention is misrouting.\n", + "m = Model()\n", + "x = m.add_variables(name=\"x\")\n", + "y = m.add_variables(name=\"y\")\n", + "m.add_constraints(2 * x + 6 * y, GREATER_EQUAL, 10)\n", + "m.add_constraints(4 * x + 2 * y, GREATER_EQUAL, 3)\n", + "m.add_objective(2 * y + x)\n", + "m.solve(solvers.available_solvers[0])\n", "\n", - "All 356 pytest items are currently green at `TOL = 1e-5`." + "print(\"solver:\", m.solver)\n", + "print(\"supports IIS?\", m.solver.supports(SolverFeature.IIS_COMPUTATION))" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.13.2" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" } }, "nbformat": 4, - "nbformat_minor": 4 + "nbformat_minor": 5 } diff --git a/linopy/__init__.py b/linopy/__init__.py index df07cc81..cf2e7832 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -39,6 +39,7 @@ tangent_lines, ) from linopy.remote import RemoteHandler +from linopy.solvers import SolverFeature try: from linopy.remote import OetcCredentials, OetcHandler, OetcSettings # noqa: F401 @@ -63,6 +64,7 @@ "QuadraticExpression", "RemoteHandler", "Slopes", + "SolverFeature", "Variable", "Variables", "align", diff --git a/linopy/model.py b/linopy/model.py index e8d3ad78..2107851f 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -86,9 +86,9 @@ from linopy.remote import OetcHandler except ImportError: OetcHandler = None # type: ignore -from linopy.solver_capabilities import SolverFeature, solver_supports from linopy.solvers import ( IO_APIS, + SolverFeature, available_solvers, ) from linopy.sos_reformulation import ( @@ -1656,11 +1656,13 @@ def solve( ) logger.info(f"Solver options:\n{options_string}") + solver_class = getattr(solvers, solvers.SolverName(solver_name).name) + if problem_fn is None: problem_fn = self.get_problem_file(io_api=io_api) if solution_fn is None: if ( - solver_supports(solver_name, SolverFeature.SOLUTION_FILE_NOT_NEEDED) + solver_class.supports(SolverFeature.SOLUTION_FILE_NOT_NEEDED) and not keep_files ): # these (solver, keep_files=False) combos do not need a solution file @@ -1674,8 +1676,8 @@ def solve( if sanitize_infinities: self.constraints.sanitize_infinities() - if self.is_quadratic and not solver_supports( - solver_name, SolverFeature.QUADRATIC_OBJECTIVE + if self.is_quadratic and not solver_class.supports( + SolverFeature.QUADRATIC_OBJECTIVE ): raise ValueError( f"Solver {solver_name} does not support quadratic problems." @@ -1689,7 +1691,7 @@ def solve( sos_reform_result = None if self.variables.sos: - supports_sos = solver_supports(solver_name, SolverFeature.SOS_CONSTRAINTS) + supports_sos = solver_class.supports(SolverFeature.SOS_CONSTRAINTS) if reformulate_sos in (True, "auto") and not supports_sos: logger.info(f"Reformulating SOS constraints for solver {solver_name}") sos_reform_result = reformulate_sos_constraints(self) @@ -1705,16 +1707,13 @@ def solve( ) if self.variables.semi_continuous: - if not solver_supports( - solver_name, SolverFeature.SEMI_CONTINUOUS_VARIABLES - ): + if not solver_class.supports(SolverFeature.SEMI_CONTINUOUS_VARIABLES): raise ValueError( f"Solver {solver_name} does not support semi-continuous variables. " "Use a solver that supports them (gurobi, cplex, highs)." ) try: - solver_class = getattr(solvers, f"{solvers.SolverName(solver_name).name}") if self.solver is not None: self.solver.close() solver = solver_class(**solver_options) @@ -1735,7 +1734,7 @@ def solve( ) else: if ( - not solver_supports(solver_name, SolverFeature.LP_FILE_NAMES) + not solver_class.supports(SolverFeature.LP_FILE_NAMES) and explicit_coordinate_names ): logger.warning( @@ -1896,7 +1895,9 @@ def compute_infeasibilities(self) -> list[int]: if solver_model is None: # Check if this is a supported solver without a stored model solver_name = self.solver_name or "unknown" - if solver_supports(solver_name, SolverFeature.IIS_COMPUTATION): + if self.solver is not None and self.solver.supports( + SolverFeature.IIS_COMPUTATION + ): raise ValueError( "No solver model available. The model must be solved first with " "a solver that supports IIS computation and the result must be infeasible." diff --git a/linopy/solver_capabilities.py b/linopy/solver_capabilities.py index f9c6aba4..142ae521 100644 --- a/linopy/solver_capabilities.py +++ b/linopy/solver_capabilities.py @@ -1,306 +1,99 @@ """ -Linopy module for solver capability tracking. +Back-compat shim for legacy solver-capability imports. -This module provides a centralized registry of solver capabilities, -replacing scattered hardcoded checks throughout the codebase. +Capability data is declared on each `Solver` subclass in `linopy.solvers`. +Prefer `Solver.features` / `Solver.supports()` over the helpers in this module. """ from __future__ import annotations +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass -from enum import Enum, auto -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version as package_version +from enum import Enum from typing import TYPE_CHECKING -from packaging.specifiers import SpecifierSet - if TYPE_CHECKING: - from collections.abc import Sequence - - -def _xpress_supports_gpu() -> bool: - """Check if installed xpress version supports GPU acceleration (>=9.8.0).""" - try: - return package_version("xpress") in SpecifierSet(">=9.8.0") - except PackageNotFoundError: - return False - - -class SolverFeature(Enum): - """Enumeration of all solver capabilities tracked by linopy.""" + from linopy.solvers import Solver, SolverFeature, _xpress_supports_gpu - # Model feature support - INTEGER_VARIABLES = auto() # Support for integer variables +__all__ = ( + "SOLVER_REGISTRY", + "SolverFeature", + "SolverInfo", + "_xpress_supports_gpu", + "get_available_solvers_with_feature", + "get_solvers_with_feature", + "solver_supports", +) - # Objective function support - QUADRATIC_OBJECTIVE = auto() - # I/O capabilities - DIRECT_API = auto() # Solve directly from Model without writing files - LP_FILE_NAMES = auto() # Support for named variables/constraints in LP files - READ_MODEL_FROM_FILE = auto() # Ability to read models from file - SOLUTION_FILE_NOT_NEEDED = auto() # Solver doesn't need a solution file +def __getattr__(name: str) -> object: + if name in {"SolverFeature", "_xpress_supports_gpu"}: + from linopy import solvers as _solvers_mod - # Advanced features - GPU_ACCELERATION = auto() # GPU-accelerated solving - IIS_COMPUTATION = auto() # Irreducible Infeasible Set computation - - # Special constraint types - SOS_CONSTRAINTS = auto() # Special Ordered Sets (SOS1/SOS2) constraints - - # Special variable types - SEMI_CONTINUOUS_VARIABLES = auto() # Semi-continuous variable support - - # Solver-specific - SOLVER_ATTRIBUTE_ACCESS = auto() # Direct access to solver variable attributes + return getattr(_solvers_mod, name) + raise AttributeError(name) @dataclass(frozen=True) class SolverInfo: - """Information about a solver's capabilities.""" + """Legacy view of a solver's capabilities. Prefer Solver.features / Solver.supports().""" name: str - features: frozenset[SolverFeature] + features: frozenset[Enum] display_name: str = "" def __post_init__(self) -> None: if not self.display_name: object.__setattr__(self, "display_name", self.name.upper()) - def supports(self, feature: SolverFeature) -> bool: - """Check if this solver supports a given feature.""" + def supports(self, feature: Enum) -> bool: return feature in self.features -# Define all solver capabilities -SOLVER_REGISTRY: dict[str, SolverInfo] = { - "gurobi": SolverInfo( - name="gurobi", - display_name="Gurobi", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.QUADRATIC_OBJECTIVE, - SolverFeature.DIRECT_API, - SolverFeature.LP_FILE_NAMES, - SolverFeature.READ_MODEL_FROM_FILE, - SolverFeature.SOLUTION_FILE_NOT_NEEDED, - SolverFeature.IIS_COMPUTATION, - SolverFeature.SOS_CONSTRAINTS, - SolverFeature.SEMI_CONTINUOUS_VARIABLES, - SolverFeature.SOLVER_ATTRIBUTE_ACCESS, - } - ), - ), - "highs": SolverInfo( - name="highs", - display_name="HiGHS", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.QUADRATIC_OBJECTIVE, - SolverFeature.DIRECT_API, - SolverFeature.LP_FILE_NAMES, - SolverFeature.READ_MODEL_FROM_FILE, - SolverFeature.SOLUTION_FILE_NOT_NEEDED, - SolverFeature.SEMI_CONTINUOUS_VARIABLES, - } - ), - ), - "glpk": SolverInfo( - name="glpk", - display_name="GLPK", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.READ_MODEL_FROM_FILE, - } - ), # No LP_FILE_NAMES support - ), - "cbc": SolverInfo( - name="cbc", - display_name="CBC", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.READ_MODEL_FROM_FILE, - } - ), # No LP_FILE_NAMES support - ), - "cplex": SolverInfo( - name="cplex", - display_name="CPLEX", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.QUADRATIC_OBJECTIVE, - SolverFeature.LP_FILE_NAMES, - SolverFeature.READ_MODEL_FROM_FILE, - SolverFeature.SOS_CONSTRAINTS, - SolverFeature.SEMI_CONTINUOUS_VARIABLES, - } - ), - ), - "xpress": SolverInfo( - name="xpress", - display_name="FICO Xpress", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.QUADRATIC_OBJECTIVE, - SolverFeature.LP_FILE_NAMES, - SolverFeature.READ_MODEL_FROM_FILE, - SolverFeature.SOLUTION_FILE_NOT_NEEDED, - SolverFeature.GPU_ACCELERATION, - SolverFeature.IIS_COMPUTATION, - } - if _xpress_supports_gpu() - else { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.QUADRATIC_OBJECTIVE, - SolverFeature.LP_FILE_NAMES, - SolverFeature.READ_MODEL_FROM_FILE, - SolverFeature.SOLUTION_FILE_NOT_NEEDED, - SolverFeature.IIS_COMPUTATION, - } - ), - ), - "knitro": SolverInfo( - name="knitro", - display_name="Artelys Knitro", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.QUADRATIC_OBJECTIVE, - SolverFeature.LP_FILE_NAMES, - SolverFeature.READ_MODEL_FROM_FILE, - SolverFeature.SOLUTION_FILE_NOT_NEEDED, - } - ), - ), - "scip": SolverInfo( - name="scip", - display_name="SCIP", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.QUADRATIC_OBJECTIVE, - SolverFeature.LP_FILE_NAMES, - SolverFeature.READ_MODEL_FROM_FILE, - SolverFeature.SOLUTION_FILE_NOT_NEEDED, - } - ), - ), - "mosek": SolverInfo( - name="mosek", - display_name="MOSEK", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.QUADRATIC_OBJECTIVE, - SolverFeature.DIRECT_API, - SolverFeature.LP_FILE_NAMES, - SolverFeature.READ_MODEL_FROM_FILE, - SolverFeature.SOLUTION_FILE_NOT_NEEDED, - } - ), - ), - "copt": SolverInfo( - name="copt", - display_name="COPT", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.QUADRATIC_OBJECTIVE, - SolverFeature.LP_FILE_NAMES, - SolverFeature.READ_MODEL_FROM_FILE, - SolverFeature.SOLUTION_FILE_NOT_NEEDED, - } - ), - ), - "mindopt": SolverInfo( - name="mindopt", - display_name="MindOpt", - features=frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.QUADRATIC_OBJECTIVE, - SolverFeature.LP_FILE_NAMES, - SolverFeature.READ_MODEL_FROM_FILE, - SolverFeature.SOLUTION_FILE_NOT_NEEDED, - } - ), - ), - "cupdlpx": SolverInfo( - name="cupdlpx", - display_name="cuPDLPx", - features=frozenset( - { - SolverFeature.DIRECT_API, - SolverFeature.GPU_ACCELERATION, - SolverFeature.SOLUTION_FILE_NOT_NEEDED, - } - ), - ), -} +def _solver_class(name: str) -> type[Solver] | None: + from linopy import solvers as _solvers_mod + try: + return getattr(_solvers_mod, _solvers_mod.SolverName(name).name, None) + except ValueError: + return None -def solver_supports(solver_name: str, feature: SolverFeature) -> bool: - """ - Check if a solver supports a given feature. - - Parameters - ---------- - solver_name : str - Name of the solver (e.g., "gurobi", "highs") - feature : SolverFeature - The feature to check for - Returns - ------- - bool - True if the solver supports the feature, False otherwise. - Returns False for unknown solvers. - """ - if solver_name not in SOLVER_REGISTRY: - return False - return SOLVER_REGISTRY[solver_name].supports(feature) +def solver_supports(solver_name: str, feature: SolverFeature) -> bool: + cls = _solver_class(solver_name) + return cls is not None and cls.supports(feature) def get_solvers_with_feature(feature: SolverFeature) -> list[str]: - """ - Get all solvers that support a given feature. - - Parameters - ---------- - feature : SolverFeature - The feature to filter by + from linopy.solvers import SolverName - Returns - ------- - list[str] - List of solver names supporting the feature - """ - return [name for name, info in SOLVER_REGISTRY.items() if info.supports(feature)] + return [n.value for n in SolverName if solver_supports(n.value, feature)] def get_available_solvers_with_feature( feature: SolverFeature, available_solvers: Sequence[str] ) -> list[str]: - """ - Get installed solvers that support a given feature. + return [s for s in get_solvers_with_feature(feature) if s in available_solvers] - Parameters - ---------- - feature : SolverFeature - The feature to filter by - available_solvers : Sequence[str] - List of currently available/installed solvers - Returns - ------- - list[str] - List of installed solver names supporting the feature - """ - return [s for s in get_solvers_with_feature(feature) if s in available_solvers] +class _LazyRegistry(Mapping[str, SolverInfo]): + def __getitem__(self, key: str) -> SolverInfo: + cls = _solver_class(key) + if cls is None: + raise KeyError(key) + return SolverInfo( + name=key, features=cls.features, display_name=cls.display_name + ) + + def __iter__(self) -> Iterator[str]: + from linopy.solvers import SolverName + + return (n.value for n in SolverName) + + def __len__(self) -> int: + from linopy.solvers import SolverName + + return len(SolverName) + + +SOLVER_REGISTRY: Mapping[str, SolverInfo] = _LazyRegistry() diff --git a/linopy/solvers.py b/linopy/solvers.py index f75b42a1..4fc9c0d6 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -18,11 +18,15 @@ from abc import ABC, abstractmethod from collections import namedtuple from collections.abc import Callable, Generator +from enum import Enum, auto +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version from pathlib import Path -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar import numpy as np import pandas as pd +from packaging.specifiers import SpecifierSet from packaging.version import parse as parse_version import linopy.io @@ -34,12 +38,31 @@ Status, TerminationCondition, ) -from linopy.solver_capabilities import ( - SOLVER_REGISTRY, - SolverFeature, - SolverInfo, - get_solvers_with_feature, -) + + +class SolverFeature(Enum): + """Enumeration of all solver capabilities tracked by linopy.""" + + INTEGER_VARIABLES = auto() + QUADRATIC_OBJECTIVE = auto() + DIRECT_API = auto() + LP_FILE_NAMES = auto() + READ_MODEL_FROM_FILE = auto() + SOLUTION_FILE_NOT_NEEDED = auto() + GPU_ACCELERATION = auto() + IIS_COMPUTATION = auto() + SOS_CONSTRAINTS = auto() + SEMI_CONTINUOUS_VARIABLES = auto() + SOLVER_ATTRIBUTE_ACCESS = auto() + + +def _xpress_supports_gpu() -> bool: + """Check if installed xpress version supports GPU acceleration (>=9.8.0).""" + try: + return package_version("xpress") in SpecifierSet(">=9.8.0") + except PackageNotFoundError: + return False + if TYPE_CHECKING: import gurobipy @@ -48,12 +71,6 @@ EnvType = TypeVar("EnvType") -# Generated from solver_capabilities registry for backward compatibility -QUADRATIC_SOLVERS = get_solvers_with_feature(SolverFeature.QUADRATIC_OBJECTIVE) -NO_SOLUTION_FILE_SOLVERS = get_solvers_with_feature( - SolverFeature.SOLUTION_FILE_NOT_NEEDED -) - FILE_IO_APIS = ["lp", "lp-polars", "mps"] IO_APIS = FILE_IO_APIS + ["direct"] @@ -223,7 +240,6 @@ class xpress_Namespaces: # type: ignore[no-redef] pass -quadratic_solvers = [s for s in QUADRATIC_SOLVERS if s in available_solvers] logger = logging.getLogger(__name__) @@ -309,6 +325,14 @@ class Solver(ABC, Generic[EnvType]): `solve_problem_from_file()` methods. """ + display_name: ClassVar[str] = "" + features: ClassVar[frozenset[SolverFeature]] = frozenset() + + @classmethod + def supports(cls, feature: SolverFeature) -> bool: + """Check if this solver supports a given feature.""" + return feature in cls.features + def __init__( self, **solver_options: Any, @@ -321,9 +345,6 @@ def __init__( self.solver_model: Any = None self.io_api: str | None = None self.env: Any = None - self.capability: SolverInfo | None = SOLVER_REGISTRY.get( - self.solver_name.value - ) self._env_stack: contextlib.ExitStack | None = None if self.solver_name.value not in available_solvers: @@ -508,6 +529,14 @@ class CBC(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "CBC" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.READ_MODEL_FROM_FILE, + } + ) + def __init__( self, **solver_options: Any, @@ -701,6 +730,14 @@ class GLPK(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "GLPK" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.READ_MODEL_FROM_FILE, + } + ) + def __init( self, **solver_options: Any, @@ -884,6 +921,19 @@ class Highs(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "HiGHS" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.DIRECT_API, + SolverFeature.LP_FILE_NAMES, + SolverFeature.READ_MODEL_FROM_FILE, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + SolverFeature.SEMI_CONTINUOUS_VARIABLES, + } + ) + def __init__( self, **solver_options: Any, @@ -1137,6 +1187,22 @@ class Gurobi(Solver["gurobipy.Env | dict[str, Any] | None"]): options for the given solver """ + display_name: ClassVar[str] = "Gurobi" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.DIRECT_API, + SolverFeature.LP_FILE_NAMES, + SolverFeature.READ_MODEL_FROM_FILE, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + SolverFeature.IIS_COMPUTATION, + SolverFeature.SOS_CONSTRAINTS, + SolverFeature.SEMI_CONTINUOUS_VARIABLES, + SolverFeature.SOLVER_ATTRIBUTE_ACCESS, + } + ) + def __init__( self, **solver_options: Any, @@ -1396,6 +1462,18 @@ class Cplex(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "CPLEX" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.LP_FILE_NAMES, + SolverFeature.READ_MODEL_FROM_FILE, + SolverFeature.SOS_CONSTRAINTS, + SolverFeature.SEMI_CONTINUOUS_VARIABLES, + } + ) + def __init__( self, **solver_options: Any, @@ -1552,6 +1630,17 @@ class SCIP(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "SCIP" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.LP_FILE_NAMES, + SolverFeature.READ_MODEL_FROM_FILE, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + } + ) + def __init__( self, **solver_options: Any, @@ -1710,6 +1799,22 @@ class Xpress(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "FICO Xpress" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.LP_FILE_NAMES, + SolverFeature.READ_MODEL_FROM_FILE, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + SolverFeature.IIS_COMPUTATION, + } + ) | ( + frozenset({SolverFeature.GPU_ACCELERATION}) + if _xpress_supports_gpu() + else frozenset() + ) + def __init__( self, **solver_options: Any, @@ -1891,6 +1996,17 @@ class Knitro(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "Artelys Knitro" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.LP_FILE_NAMES, + SolverFeature.READ_MODEL_FROM_FILE, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + } + ) + def __init__( self, **solver_options: Any, @@ -2150,6 +2266,18 @@ class Mosek(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "MOSEK" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.DIRECT_API, + SolverFeature.LP_FILE_NAMES, + SolverFeature.READ_MODEL_FROM_FILE, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + } + ) + def __init__( self, **solver_options: Any, @@ -2523,6 +2651,17 @@ class COPT(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "COPT" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.LP_FILE_NAMES, + SolverFeature.READ_MODEL_FROM_FILE, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + } + ) + def __init( self, **solver_options: Any, @@ -2666,6 +2805,17 @@ class MindOpt(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "MindOpt" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.LP_FILE_NAMES, + SolverFeature.READ_MODEL_FROM_FILE, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + } + ) + def __init( self, **solver_options: Any, @@ -2831,6 +2981,15 @@ class cuPDLPx(Solver[None]): options for the given solver """ + display_name: ClassVar[str] = "cuPDLPx" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.DIRECT_API, + SolverFeature.GPU_ACCELERATION, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + } + ) + def __init__( self, **solver_options: Any, @@ -3079,3 +3238,25 @@ def _set_solver_params(self, cu_model: cupdlpx.Model) -> None: """ for k, v in self.solver_options.items(): cu_model.setParam(k, v) + + +def _solver_class_for(name: str) -> type[Solver] | None: + try: + return globals().get(SolverName(name).name) + except ValueError: + return None + + +QUADRATIC_SOLVERS = [ + n.value + for n in SolverName + if (cls := _solver_class_for(n.value)) is not None + and cls.supports(SolverFeature.QUADRATIC_OBJECTIVE) +] +NO_SOLUTION_FILE_SOLVERS = [ + n.value + for n in SolverName + if (cls := _solver_class_for(n.value)) is not None + and cls.supports(SolverFeature.SOLUTION_FILE_NOT_NEEDED) +] +quadratic_solvers = [s for s in QUADRATIC_SOLVERS if s in available_solvers] diff --git a/linopy/variables.py b/linopy/variables.py index b6a74f29..cbf2fb87 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -62,7 +62,6 @@ SOS_TYPE_ATTR, TERM_DIM, ) -from linopy.solver_capabilities import SolverFeature, solver_supports from linopy.types import ( ConstantLike, DimsLike, @@ -977,6 +976,8 @@ def get_solver_attribute(self, attr: str) -> DataArray: ------- xr.DataArray """ + from linopy.solver_capabilities import SolverFeature, solver_supports + solver_model = self.model.solver_model if not solver_supports( self.model.solver_name or "", SolverFeature.SOLVER_ATTRIBUTE_ACCESS diff --git a/pyproject.toml b/pyproject.toml index cfbaa10a..f5fa135a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ dev = [ "types-requests", "gurobipy", "highspy", + "jupyter", ] benchmarks = [ "pytest-benchmark", diff --git a/test/test_solvers.py b/test/test_solvers.py index e4ffdb85..d62f37e6 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -14,7 +14,13 @@ from linopy import GREATER_EQUAL, Model, solvers from linopy.constants import Result, Solution, Status -from linopy.solver_capabilities import SolverFeature, solver_supports +from linopy.solver_capabilities import ( + SOLVER_REGISTRY, + SolverFeature, + SolverInfo, + _xpress_supports_gpu, + solver_supports, +) @pytest.fixture @@ -318,3 +324,55 @@ def test_gurobi_environment_with_gurobi_env(model: Model, tmp_path: Path) -> Non gurobi.solve_problem(model=model, solution_fn=sol_file, env=env) assert result.status.is_ok assert log2_file.exists() + + +@pytest.mark.parametrize( + "solver_cls, feature, expected", + [ + (solvers.Gurobi, SolverFeature.SOS_CONSTRAINTS, True), + (solvers.Gurobi, SolverFeature.GPU_ACCELERATION, False), + (solvers.Highs, SolverFeature.SOS_CONSTRAINTS, False), + (solvers.Highs, SolverFeature.SEMI_CONTINUOUS_VARIABLES, True), + (solvers.CBC, SolverFeature.LP_FILE_NAMES, False), + (solvers.CBC, SolverFeature.INTEGER_VARIABLES, True), + (solvers.cuPDLPx, SolverFeature.DIRECT_API, True), + (solvers.cuPDLPx, SolverFeature.GPU_ACCELERATION, True), + (solvers.cuPDLPx, SolverFeature.QUADRATIC_OBJECTIVE, False), + (solvers.PIPS, SolverFeature.INTEGER_VARIABLES, False), + ], +) +def test_solver_class_supports_feature( + solver_cls: type, feature: SolverFeature, expected: bool +) -> None: + assert solver_cls.supports(feature) is expected + + +def test_solver_instance_supports_matches_class() -> None: + feature = SolverFeature.QUADRATIC_OBJECTIVE + assert solvers.Gurobi.supports(feature) is True + if "gurobi" in solvers.available_solvers: + assert solvers.Gurobi().supports(feature) is True + + +@pytest.mark.parametrize("solver_name", [n.value for n in solvers.SolverName]) +def test_capability_shim_round_trips(solver_name: str) -> None: + solver_cls = getattr(solvers, solvers.SolverName(solver_name).name) + for feature in SolverFeature: + assert solver_supports(solver_name, feature) == solver_cls.supports(feature) + + +def test_solver_registry_iter_and_index() -> None: + names = list(SOLVER_REGISTRY) + assert "gurobi" in names + for name in names: + info = SOLVER_REGISTRY[name] + assert isinstance(info, SolverInfo) + assert isinstance(info.features, frozenset) + assert info.name == name + + +@pytest.mark.skipif( + "xpress" not in set(solvers.available_solvers), reason="Xpress is not installed" +) +def test_xpress_gpu_feature_reflects_installed_version() -> None: + assert solvers.Xpress.supports(SolverFeature.GPU_ACCELERATION) == _xpress_supports_gpu() From 9d9dd4dd42bab9eed0fa93727b50a3b25a196b8c Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 12 May 2026 14:23:43 +0200 Subject: [PATCH 08/27] refactor: lift to_solver_model/resolve onto Model and drop sense arg Stash `sense` on the Solver instance in `to_solver_model` and make `Solver.resolve()` take no args. Add `Model.to_solver_model(name)` and `Model.resolve()` wrappers so the two-step direct-API flow lives on the model. Update the direct-API test and re-run the piecewise notebook. --- ...cewise-feasibility-tests-walkthrough.ipynb | 68 +++++++++---------- linopy/model.py | 26 +++++++ linopy/solvers.py | 29 +++++--- test/test_solvers.py | 12 ++-- 4 files changed, 82 insertions(+), 53 deletions(-) diff --git a/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb b/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb index e985c8d8..4c324018 100644 --- a/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb +++ b/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb @@ -38,10 +38,10 @@ "id": "a8fd9c74", "metadata": { "execution": { - "iopub.execute_input": "2026-05-12T09:01:38.713028Z", - "iopub.status.busy": "2026-05-12T09:01:38.712887Z", - "iopub.status.idle": "2026-05-12T09:01:39.324386Z", - "shell.execute_reply": "2026-05-12T09:01:39.324012Z" + "iopub.execute_input": "2026-05-12T10:36:40.801067Z", + "iopub.status.busy": "2026-05-12T10:36:40.800695Z", + "iopub.status.idle": "2026-05-12T10:36:41.331745Z", + "shell.execute_reply": "2026-05-12T10:36:41.331426Z" } }, "outputs": [ @@ -79,10 +79,10 @@ "id": "b7756f8e", "metadata": { "execution": { - "iopub.execute_input": "2026-05-12T09:01:39.326242Z", - "iopub.status.busy": "2026-05-12T09:01:39.326013Z", - "iopub.status.idle": "2026-05-12T09:01:39.340797Z", - "shell.execute_reply": "2026-05-12T09:01:39.340336Z" + "iopub.execute_input": "2026-05-12T10:36:41.333374Z", + "iopub.status.busy": "2026-05-12T10:36:41.333113Z", + "iopub.status.idle": "2026-05-12T10:36:41.342647Z", + "shell.execute_reply": "2026-05-12T10:36:41.342386Z" } }, "outputs": [ @@ -110,10 +110,10 @@ "id": "0403ecce", "metadata": { "execution": { - "iopub.execute_input": "2026-05-12T09:01:39.342087Z", - "iopub.status.busy": "2026-05-12T09:01:39.341872Z", - "iopub.status.idle": "2026-05-12T09:01:39.372020Z", - "shell.execute_reply": "2026-05-12T09:01:39.371614Z" + "iopub.execute_input": "2026-05-12T10:36:41.343840Z", + "iopub.status.busy": "2026-05-12T10:36:41.343725Z", + "iopub.status.idle": "2026-05-12T10:36:41.360408Z", + "shell.execute_reply": "2026-05-12T10:36:41.360126Z" } }, "outputs": [ @@ -450,10 +450,10 @@ "id": "cd259d73", "metadata": { "execution": { - "iopub.execute_input": "2026-05-12T09:01:39.373376Z", - "iopub.status.busy": "2026-05-12T09:01:39.373162Z", - "iopub.status.idle": "2026-05-12T09:01:39.385969Z", - "shell.execute_reply": "2026-05-12T09:01:39.385464Z" + "iopub.execute_input": "2026-05-12T10:36:41.361938Z", + "iopub.status.busy": "2026-05-12T10:36:41.361804Z", + "iopub.status.idle": "2026-05-12T10:36:41.371889Z", + "shell.execute_reply": "2026-05-12T10:36:41.371592Z" } }, "outputs": [ @@ -501,10 +501,10 @@ "id": "f44e3093", "metadata": { "execution": { - "iopub.execute_input": "2026-05-12T09:01:39.387637Z", - "iopub.status.busy": "2026-05-12T09:01:39.387412Z", - "iopub.status.idle": "2026-05-12T09:01:39.403181Z", - "shell.execute_reply": "2026-05-12T09:01:39.402742Z" + "iopub.execute_input": "2026-05-12T10:36:41.373347Z", + "iopub.status.busy": "2026-05-12T10:36:41.373235Z", + "iopub.status.idle": "2026-05-12T10:36:41.383248Z", + "shell.execute_reply": "2026-05-12T10:36:41.382950Z" } }, "outputs": [ @@ -535,10 +535,10 @@ "id": "776d220b", "metadata": { "execution": { - "iopub.execute_input": "2026-05-12T09:01:39.404450Z", - "iopub.status.busy": "2026-05-12T09:01:39.404244Z", - "iopub.status.idle": "2026-05-12T09:01:39.418918Z", - "shell.execute_reply": "2026-05-12T09:01:39.418492Z" + "iopub.execute_input": "2026-05-12T10:36:41.384574Z", + "iopub.status.busy": "2026-05-12T10:36:41.384463Z", + "iopub.status.idle": "2026-05-12T10:36:41.394339Z", + "shell.execute_reply": "2026-05-12T10:36:41.394052Z" } }, "outputs": [ @@ -578,10 +578,10 @@ "id": "4c74cd49", "metadata": { "execution": { - "iopub.execute_input": "2026-05-12T09:01:39.420308Z", - "iopub.status.busy": "2026-05-12T09:01:39.420131Z", - "iopub.status.idle": "2026-05-12T09:01:39.436186Z", - "shell.execute_reply": "2026-05-12T09:01:39.435677Z" + "iopub.execute_input": "2026-05-12T10:36:41.395592Z", + "iopub.status.busy": "2026-05-12T10:36:41.395493Z", + "iopub.status.idle": "2026-05-12T10:36:41.404649Z", + "shell.execute_reply": "2026-05-12T10:36:41.404392Z" } }, "outputs": [ @@ -621,10 +621,10 @@ "id": "1ac953f0", "metadata": { "execution": { - "iopub.execute_input": "2026-05-12T09:01:39.437867Z", - "iopub.status.busy": "2026-05-12T09:01:39.437621Z", - "iopub.status.idle": "2026-05-12T09:01:39.732262Z", - "shell.execute_reply": "2026-05-12T09:01:39.731765Z" + "iopub.execute_input": "2026-05-12T10:36:41.406074Z", + "iopub.status.busy": "2026-05-12T10:36:41.405878Z", + "iopub.status.idle": "2026-05-12T10:36:41.636394Z", + "shell.execute_reply": "2026-05-12T10:36:41.635958Z" } }, "outputs": [ @@ -639,7 +639,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Read LP format model from file /tmp/linopy-problem-pi2lkhq7.lp\n" + "Read LP format model from file /tmp/linopy-problem-ft6epgcz.lp\n" ] }, { @@ -814,7 +814,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "solver: Gurobi(name='gurobi', status='ok', io_api='lp', solver_model=loaded, env=active, objective=3.3, runtime=0.00488s)\n", + "solver: Gurobi(name='gurobi', status='ok', io_api='lp', solver_model=loaded, env=active, objective=3.3, runtime=0.00437s)\n", "supports IIS? True\n" ] } diff --git a/linopy/model.py b/linopy/model.py index 2107851f..d2e0015c 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1768,6 +1768,32 @@ def solve( if sos_reform_result is not None: undo_sos_reformulation(self, sos_reform_result) + def to_solver_model(self, solver_name: str, **kwargs: Any) -> Any: + """ + Build the solver-native model for `solver_name` without solving. + + Instantiates the solver, attaches it as `self.solver`, and returns + the native model object (e.g. `gurobipy.Model`). Pair with `resolve()` + for a two-step build-then-solve workflow. + """ + solver_class = getattr(solvers, solvers.SolverName(solver_name).name) + if self.solver is not None: + self.solver.close() + self.solver = solver_class() + return self.solver.to_solver_model(self, **kwargs) + + def resolve(self) -> tuple[str, str]: + """ + Solve the previously built solver model and apply the result. + + Requires a prior `to_solver_model(...)`. Returns the + `(status, termination_condition)` tuple from `apply_result`. + """ + if self.solver is None: + raise RuntimeError("call to_solver_model() first") + self.solver.resolve() + return self.apply_result() + def apply_result(self, result: Result | None = None) -> tuple[str, str]: if result is None: if self.solver is None or self.solver.status is None: diff --git a/linopy/solvers.py b/linopy/solvers.py index 4fc9c0d6..bf8f478b 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -344,6 +344,7 @@ def __init__( self.report: SolverReport | None = None self.solver_model: Any = None self.io_api: str | None = None + self.sense: str | None = None self.env: Any = None self._env_stack: contextlib.ExitStack | None = None @@ -357,12 +358,14 @@ def to_solver_model(self, model: Model, **kwargs: Any) -> Any: def update_solver_model(self, model: Model, **kwargs: Any) -> None: raise NotImplementedError - def resolve(self, sense: str) -> Result: + def resolve(self) -> Result: if self.solver_model is None: raise RuntimeError("call to_solver_model first") - return self._resolve(sense) + if self.sense is None: + raise RuntimeError("sense not set; call to_solver_model first") + return self._resolve() - def _resolve(self, sense: str) -> Result: + def _resolve(self) -> Result: raise NotImplementedError def close(self) -> None: @@ -968,6 +971,7 @@ def to_solver_model( self._set_solver_params(h, log_fn) self.solver_model = h self.io_api = "direct" + self.sense = model.sense return h def solve_problem_from_model( @@ -998,8 +1002,8 @@ def solve_problem_from_model( sense=model.sense, ) - def _resolve(self, sense: str) -> Result: - return self._solve(self.solver_model, io_api=self.io_api, sense=sense) + def _resolve(self) -> Result: + return self._solve(self.solver_model, io_api=self.io_api, sense=self.sense) def solve_problem_from_file( self, @@ -1239,6 +1243,7 @@ def to_solver_model( ) self.solver_model = m self.io_api = "direct" + self.sense = model.sense return m def solve_problem_from_model( @@ -1268,7 +1273,7 @@ def solve_problem_from_model( sense=model.sense, ) - def _resolve(self, sense: str) -> Result: + def _resolve(self) -> Result: return self._solve( self.solver_model, solution_fn=None, @@ -1276,7 +1281,7 @@ def _resolve(self, sense: str) -> Result: warmstart_fn=None, basis_fn=None, io_api=self.io_api, - sense=sense, + sense=self.sense, ) def solve_problem_from_file( @@ -2347,7 +2352,7 @@ def solve_problem_from_model( sense=model.sense, ) - def _resolve(self, sense: str) -> Result: + def _resolve(self) -> Result: return self._solve( self.solver_model, solution_fn=None, @@ -2355,7 +2360,7 @@ def _resolve(self, sense: str) -> Result: warmstart_fn=None, basis_fn=None, io_api=self.io_api, - sense=sense, + sense=self.sense, ) def to_solver_model( @@ -2375,6 +2380,7 @@ def to_solver_model( ) self.solver_model = m self.io_api = "direct" + self.sense = model.sense return m def solve_problem_from_file( @@ -3112,14 +3118,15 @@ def to_solver_model(self, model: Model, **kwargs: Any) -> cupdlpx.Model: cu_model = model.to_cupdlpx() self.solver_model = cu_model self.io_api = "direct" + self.sense = model.sense return cu_model - def _resolve(self, sense: str) -> Result: + def _resolve(self) -> Result: return self._solve( self.solver_model, l_model=None, io_api=self.io_api, - sense=sense, + sense=self.sense, ) def _solve( diff --git a/test/test_solvers.py b/test/test_solvers.py index d62f37e6..4cdeb28f 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -60,11 +60,8 @@ def test_result_carries_solver_name(simple_model: Model, solver: str) -> None: def test_to_solver_model_then_resolve(simple_model: Model, solver: str) -> None: if not solver_supports(solver, SolverFeature.DIRECT_API): pytest.skip("Solver does not support direct API.") - solver_enum = solvers.SolverName(solver.lower()) - solver_class = getattr(solvers, solver_enum.name) - instance = solver_class() - instance.to_solver_model(simple_model) - result = instance.resolve(simple_model.sense) + simple_model.to_solver_model(solver) + simple_model.resolve() reference = Model(chunk=None) rx = reference.add_variables(name="x") @@ -74,9 +71,8 @@ def test_to_solver_model_then_resolve(simple_model: Model, solver: str) -> None: reference.add_objective(2 * ry + rx) reference.solve(solver, io_api="direct") - assert result.status.is_ok - assert result.solution is not None - assert np.isclose(result.solution.objective, reference.objective.value) + assert simple_model.status == "ok" + assert np.isclose(simple_model.objective.value, reference.objective.value) def test_apply_result_explicit(simple_model: Model) -> None: From 17ecf368156cda0a59eea8179ca252c628fef4b9 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 12 May 2026 22:54:28 +0200 Subject: [PATCH 09/27] Fix two-step direct solver state and label mapping --- linopy/model.py | 51 +++++++++++++++++++++++++++++++++--- linopy/solvers.py | 55 ++++++++++++++++++++++++++++++--------- test/test_optimization.py | 16 ++++++++++++ test/test_solvers.py | 51 ++++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 15 deletions(-) diff --git a/linopy/model.py b/linopy/model.py index d2e0015c..447de10a 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -317,10 +317,26 @@ def __init__( def solver_model(self) -> Any: return self.solver.solver_model if self.solver is not None else None + @solver_model.setter + def solver_model(self, value: Any) -> None: + if value is not None: + raise AttributeError("solver state is managed via model.solver") + if self.solver is not None: + self.solver.close() + self.solver = None + @property def solver_name(self) -> str | None: return self.solver.solver_name.value if self.solver is not None else None + @solver_name.setter + def solver_name(self, value: str | None) -> None: + if value is not None: + raise AttributeError("solver state is managed via model.solver") + if self.solver is not None: + self.solver.close() + self.solver = None + @property def matrices(self) -> MatrixAccessor: """Matrix representation of the model, computed fresh on each access.""" @@ -1768,7 +1784,15 @@ def solve( if sos_reform_result is not None: undo_sos_reformulation(self, sos_reform_result) - def to_solver_model(self, solver_name: str, **kwargs: Any) -> Any: + def to_solver_model( + self, + solver_name: str, + explicit_coordinate_names: bool = False, + set_names: bool | None = None, + env: Any = None, + log_fn: Path | None = None, + **solver_options: Any, + ) -> Any: """ Build the solver-native model for `solver_name` without solving. @@ -1777,10 +1801,28 @@ def to_solver_model(self, solver_name: str, **kwargs: Any) -> Any: for a two-step build-then-solve workflow. """ solver_class = getattr(solvers, solvers.SolverName(solver_name).name) + if not solver_class.supports(SolverFeature.DIRECT_API): + raise NotImplementedError( + f"Solver {solver_name} does not support direct API model export." + ) + if set_names is None: + set_names = self.set_names_in_solver_io if self.solver is not None: self.solver.close() - self.solver = solver_class() - return self.solver.to_solver_model(self, **kwargs) + solver = solver_class(**solver_options) + self.solver = solver + try: + return solver.to_solver_model( + self, + explicit_coordinate_names=explicit_coordinate_names, + set_names=set_names, + env=env, + log_fn=to_path(log_fn), + ) + except Exception: + solver.close() + self.solver = None + raise def resolve(self) -> tuple[str, str]: """ @@ -1857,6 +1899,9 @@ def _mock_solve( solver_name = "mock" logger.info(f" Solve problem using {solver_name.title()} solver") + if self.solver is not None: + self.solver.close() + self.solver = None # reset result self.reset_solution() diff --git a/linopy/solvers.py b/linopy/solvers.py index bf8f478b..991497fc 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -346,6 +346,8 @@ def __init__( self.io_api: str | None = None self.sense: str | None = None self.env: Any = None + self._vlabels: np.ndarray | None = None + self._clabels: np.ndarray | None = None self._env_stack: contextlib.ExitStack | None = None if self.solver_name.value not in available_solvers: @@ -358,6 +360,10 @@ def to_solver_model(self, model: Model, **kwargs: Any) -> Any: def update_solver_model(self, model: Model, **kwargs: Any) -> None: raise NotImplementedError + def _store_labels(self, model: Model) -> None: + self._vlabels = model.matrices.vlabels.copy() + self._clabels = model.matrices.clabels.copy() + def resolve(self) -> Result: if self.solver_model is None: raise RuntimeError("call to_solver_model first") @@ -374,6 +380,8 @@ def close(self) -> None: self.env = None self.solver_model = None self._env_stack = None + self._vlabels = None + self._clabels = None def __del__(self) -> None: with contextlib.suppress(Exception): @@ -968,6 +976,7 @@ def to_solver_model( explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, ) + self._store_labels(model) self._set_solver_params(h, log_fn) self.solver_model = h self.io_api = "direct" @@ -1045,6 +1054,8 @@ def solve_problem_from_file( self._set_solver_params(h, log_fn) h.readModel(problem_fn_) + self._vlabels = None + self._clabels = None self.solver_model = h self.io_api = read_io_api_from_problem_file(problem_fn) @@ -1156,6 +1167,9 @@ def get_solver_solution() -> Solution: if model is not None: sol = pd.Series(solution.col_value, model.matrices.vlabels, dtype=float) dual = pd.Series(solution.row_dual, model.matrices.clabels, dtype=float) + elif self._vlabels is not None and self._clabels is not None: + sol = pd.Series(solution.col_value, self._vlabels, dtype=float) + dual = pd.Series(solution.row_dual, self._clabels, dtype=float) else: sol = pd.Series(solution.col_value, h.getLp().col_names_, dtype=float) dual = pd.Series(solution.row_dual, h.getLp().row_names_, dtype=float) @@ -1241,6 +1255,7 @@ def to_solver_model( explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, ) + self._store_labels(model) self.solver_model = m self.io_api = "direct" self.sense = model.sense @@ -1422,12 +1437,18 @@ def _solve( def get_solver_solution() -> Solution: objective = m.ObjVal - sol = pd.Series({v.VarName: v.x for v in m.getVars()}, dtype=float) # type: ignore + vars = m.getVars() + if self._vlabels is not None: + sol = pd.Series([v.X for v in vars], self._vlabels, dtype=float) + else: + sol = pd.Series({v.VarName: v.X for v in vars}, dtype=float) try: - dual = pd.Series( - {c.ConstrName: c.Pi for c in m.getConstrs()}, dtype=float - ) + constrs = m.getConstrs() + if self._clabels is not None: + dual = pd.Series([c.Pi for c in constrs], self._clabels, dtype=float) + else: + dual = pd.Series({c.ConstrName: c.Pi for c in constrs}, dtype=float) except AttributeError: logger.warning("Dual values of MILP couldn't be parsed") dual = pd.Series(dtype=float) @@ -2378,6 +2399,7 @@ def to_solver_model( explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, ) + self._store_labels(model) self.solver_model = m self.io_api = "direct" self.sense = model.sense @@ -2621,14 +2643,22 @@ def _solve( def get_solver_solution() -> Solution: objective = m.getprimalobj(soltype) - sol = m.getxx(soltype) - sol = {m.getvarname(i): sol[i] for i in range(m.getnumvar())} - sol = pd.Series(sol, dtype=float) + sol_values = m.getxx(soltype) + if self._vlabels is not None: + sol = pd.Series(sol_values, self._vlabels, dtype=float) + else: + sol = {m.getvarname(i): sol_values[i] for i in range(m.getnumvar())} + sol = pd.Series(sol, dtype=float) try: - dual = m.gety(soltype) - dual = {m.getconname(i): dual[i] for i in range(m.getnumcon())} - dual = pd.Series(dual, dtype=float) + dual_values = m.gety(soltype) + if self._clabels is not None: + dual = pd.Series(dual_values, self._clabels, dtype=float) + else: + dual = { + m.getconname(i): dual_values[i] for i in range(m.getnumcon()) + } + dual = pd.Series(dual, dtype=float) except (mosek.Error, AttributeError): logger.warning("Dual values of MILP couldn't be parsed") dual = pd.Series(dtype=float) @@ -3116,6 +3146,7 @@ def to_solver_model(self, model: Model, **kwargs: Any) -> cupdlpx.Model: msg = "cuPDLPx does not currently support QP or MILP problems." raise NotImplementedError(msg) cu_model = model.to_cupdlpx() + self._store_labels(model) self.solver_model = cu_model self.io_api = "direct" self.sense = model.sense @@ -3210,8 +3241,8 @@ def _solve( def get_solver_solution() -> Solution: objective = cu_model.ObjVal - vlabels = None if l_model is None else l_model.matrices.vlabels - clabels = None if l_model is None else l_model.matrices.clabels + vlabels = l_model.matrices.vlabels if l_model is not None else self._vlabels + clabels = l_model.matrices.clabels if l_model is not None else self._clabels sol = pd.Series(cu_model.X, vlabels, dtype=float) dual = pd.Series(cu_model.Pi, clabels, dtype=float) diff --git a/test/test_optimization.py b/test/test_optimization.py index 07d23fa2..addf777f 100644 --- a/test/test_optimization.py +++ b/test/test_optimization.py @@ -496,6 +496,22 @@ def test_mock_solve(model_maximization: Model) -> None: assert (x_solution == 0).all() +@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS is not installed") +def test_mock_solve_clears_existing_solver_state(model: Model) -> None: + status, condition = model.solve(solver_name="highs", io_api="direct") + assert status == "ok" + assert model.solver is not None + assert model.solver_model is not None + assert model.solver_name == "highs" + + status, condition = model.solve(solver="some_non_existant_solver", mock_solve=True) + + assert status == "ok" + assert model.solver is None + assert model.solver_model is None + assert model.solver_name is None + + @pytest.mark.parametrize("solver,io_api,explicit_coordinate_names", params) def test_default_settings_chunked( model_chunked: Model, solver: str, io_api: str, explicit_coordinate_names: bool diff --git a/test/test_solvers.py b/test/test_solvers.py index 4cdeb28f..ee4cda9a 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -49,6 +49,8 @@ def test_solver_instance_attached_after_solve( @pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) def test_result_carries_solver_name(simple_model: Model, solver: str) -> None: + if not solver_supports(solver, SolverFeature.DIRECT_API): + pytest.skip("Solver does not support direct API.") solver_enum = solvers.SolverName(solver.lower()) solver_class = getattr(solvers, solver_enum.name) instance = solver_class() @@ -75,6 +77,55 @@ def test_to_solver_model_then_resolve(simple_model: Model, solver: str) -> None: assert np.isclose(simple_model.objective.value, reference.objective.value) +@pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) +def test_to_solver_model_set_names_false_resolve( + simple_model: Model, solver: str +) -> None: + if not solver_supports(solver, SolverFeature.DIRECT_API): + pytest.skip("Solver does not support direct API.") + simple_model.to_solver_model(solver, set_names=False) + status, condition = simple_model.resolve() + + assert status == "ok" + assert condition == "optimal" + assert simple_model.objective.value == pytest.approx(3.3) + assert float(simple_model.variables["x"].solution) == pytest.approx(-0.1) + assert float(simple_model.variables["y"].solution) == pytest.approx(1.7) + + +@pytest.mark.skipif( + "highs" not in set(solvers.available_solvers), reason="HiGHS is not installed" +) +def test_highs_to_solver_model_applies_solver_options(simple_model: Model) -> None: + highs_model = simple_model.to_solver_model("highs", time_limit=123) + + option_status, time_limit = highs_model.getOptionValue("time_limit") + assert str(option_status) == "HighsStatus.kOk" + assert time_limit == 123 + + +@pytest.mark.skipif( + "highs" not in set(solvers.available_solvers), reason="HiGHS is not installed" +) +def test_solver_state_compatibility_setters(simple_model: Model) -> None: + simple_model.to_solver_model("highs") + simple_model.solver_model = None + assert simple_model.solver is None + assert simple_model.solver_model is None + assert simple_model.solver_name is None + + simple_model.to_solver_model("highs") + simple_model.solver_name = None + assert simple_model.solver is None + assert simple_model.solver_model is None + assert simple_model.solver_name is None + + with pytest.raises(AttributeError, match="managed via model.solver"): + simple_model.solver_model = object() + with pytest.raises(AttributeError, match="managed via model.solver"): + simple_model.solver_name = "highs" + + def test_apply_result_explicit(simple_model: Model) -> None: x_labels = simple_model.variables["x"].labels.values y_labels = simple_model.variables["y"].labels.values From 490e7ebdf4a4b8d6ccd31f4f5d31ee9ce538c22c Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 12 May 2026 23:06:56 +0200 Subject: [PATCH 10/27] refactor: rename two-step solve API to prepare_solver/run_solver Model.to_solver_model -> prepare_solver and Model.resolve -> run_solver (plus Solver.resolve/_resolve -> run/_run). Avoids the awkward "resolve on first call" reading. Solver.to_solver_model is kept since it accurately produces the native solver model. --- doc/release_notes.rst | 5 +++++ linopy/model.py | 14 +++++++------- linopy/solvers.py | 14 +++++++------- test/test_solvers.py | 20 ++++++++++---------- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 75c316a0..68c89a98 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,11 @@ Release Notes Upcoming Version ---------------- +* Solver refactor: solver state now lives on a stateful ``Solver`` instance attached to ``Model.solver``. ``Model.solver_model`` and ``Model.solver_name`` become read-only properties delegating to ``model.solver`` (assigning anything other than ``None`` raises; setting ``None`` closes the solver). ``Model.solver_name`` may be ``None`` before a solve. +* Two-step solve workflow: ``Model.prepare_solver(solver_name, ...)`` builds the native solver model without solving, and ``Model.run_solver()`` runs it. ``Model.apply_result(result=None)`` exposes the solution-mapping step and defaults to the state on ``model.solver``. +* Solver capabilities are declared as ``features: frozenset[SolverFeature]`` ClassVars on each ``Solver`` subclass; use ``Solver.supports(feature)``. ``SolverFeature`` is now exported from ``linopy`` (and from ``linopy.solvers``); ``linopy.solver_capabilities`` remains as a back-compat shim with a lazy ``SOLVER_REGISTRY`` mapping. +* ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``. +* Direct-API solvers (HiGHS, Gurobi, Mosek, cuPDLPx) cache variable/constraint labels on the ``Solver`` instance, so solutions are reconstructed by label rather than by solver-side names — fixes label mapping when names are not set on the solver. * Increase speed of direct solver communication (~10x) in conversion functions like `to_highspy` through faster matrix creation (see below), leading to significant overall speed-up when setting `io_api="direct"`. * Add ``CSRConstraint``, a memory-efficient immutable constraint representation backed by scipy CSR sparse matrices. Provides up to 90% memory savings for constraints with many terms and 30–120x faster matrix generation for direct solver APIs. - Add ``freeze_constraints`` parameter to ``Model`` for globally storing constraints in CSR format on ``add_constraints``. diff --git a/linopy/model.py b/linopy/model.py index 447de10a..af2da283 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1784,7 +1784,7 @@ def solve( if sos_reform_result is not None: undo_sos_reformulation(self, sos_reform_result) - def to_solver_model( + def prepare_solver( self, solver_name: str, explicit_coordinate_names: bool = False, @@ -1797,7 +1797,7 @@ def to_solver_model( Build the solver-native model for `solver_name` without solving. Instantiates the solver, attaches it as `self.solver`, and returns - the native model object (e.g. `gurobipy.Model`). Pair with `resolve()` + the native model object (e.g. `gurobipy.Model`). Pair with `run_solver()` for a two-step build-then-solve workflow. """ solver_class = getattr(solvers, solvers.SolverName(solver_name).name) @@ -1824,16 +1824,16 @@ def to_solver_model( self.solver = None raise - def resolve(self) -> tuple[str, str]: + def run_solver(self) -> tuple[str, str]: """ - Solve the previously built solver model and apply the result. + Solve the previously prepared solver model and apply the result. - Requires a prior `to_solver_model(...)`. Returns the + Requires a prior `prepare_solver(...)`. Returns the `(status, termination_condition)` tuple from `apply_result`. """ if self.solver is None: - raise RuntimeError("call to_solver_model() first") - self.solver.resolve() + raise RuntimeError("call prepare_solver() first") + self.solver.run() return self.apply_result() def apply_result(self, result: Result | None = None) -> tuple[str, str]: diff --git a/linopy/solvers.py b/linopy/solvers.py index 991497fc..0184e75e 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -364,14 +364,14 @@ def _store_labels(self, model: Model) -> None: self._vlabels = model.matrices.vlabels.copy() self._clabels = model.matrices.clabels.copy() - def resolve(self) -> Result: + def run(self) -> Result: if self.solver_model is None: raise RuntimeError("call to_solver_model first") if self.sense is None: raise RuntimeError("sense not set; call to_solver_model first") - return self._resolve() + return self._run() - def _resolve(self) -> Result: + def _run(self) -> Result: raise NotImplementedError def close(self) -> None: @@ -1011,7 +1011,7 @@ def solve_problem_from_model( sense=model.sense, ) - def _resolve(self) -> Result: + def _run(self) -> Result: return self._solve(self.solver_model, io_api=self.io_api, sense=self.sense) def solve_problem_from_file( @@ -1288,7 +1288,7 @@ def solve_problem_from_model( sense=model.sense, ) - def _resolve(self) -> Result: + def _run(self) -> Result: return self._solve( self.solver_model, solution_fn=None, @@ -2373,7 +2373,7 @@ def solve_problem_from_model( sense=model.sense, ) - def _resolve(self) -> Result: + def _run(self) -> Result: return self._solve( self.solver_model, solution_fn=None, @@ -3152,7 +3152,7 @@ def to_solver_model(self, model: Model, **kwargs: Any) -> cupdlpx.Model: self.sense = model.sense return cu_model - def _resolve(self) -> Result: + def _run(self) -> Result: return self._solve( self.solver_model, l_model=None, diff --git a/test/test_solvers.py b/test/test_solvers.py index ee4cda9a..9e1783b4 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -59,11 +59,11 @@ def test_result_carries_solver_name(simple_model: Model, solver: str) -> None: @pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) -def test_to_solver_model_then_resolve(simple_model: Model, solver: str) -> None: +def test_prepare_solver_then_run(simple_model: Model, solver: str) -> None: if not solver_supports(solver, SolverFeature.DIRECT_API): pytest.skip("Solver does not support direct API.") - simple_model.to_solver_model(solver) - simple_model.resolve() + simple_model.prepare_solver(solver) + simple_model.run_solver() reference = Model(chunk=None) rx = reference.add_variables(name="x") @@ -78,13 +78,13 @@ def test_to_solver_model_then_resolve(simple_model: Model, solver: str) -> None: @pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) -def test_to_solver_model_set_names_false_resolve( +def test_prepare_solver_set_names_false_run( simple_model: Model, solver: str ) -> None: if not solver_supports(solver, SolverFeature.DIRECT_API): pytest.skip("Solver does not support direct API.") - simple_model.to_solver_model(solver, set_names=False) - status, condition = simple_model.resolve() + simple_model.prepare_solver(solver, set_names=False) + status, condition = simple_model.run_solver() assert status == "ok" assert condition == "optimal" @@ -96,8 +96,8 @@ def test_to_solver_model_set_names_false_resolve( @pytest.mark.skipif( "highs" not in set(solvers.available_solvers), reason="HiGHS is not installed" ) -def test_highs_to_solver_model_applies_solver_options(simple_model: Model) -> None: - highs_model = simple_model.to_solver_model("highs", time_limit=123) +def test_highs_prepare_solver_applies_solver_options(simple_model: Model) -> None: + highs_model = simple_model.prepare_solver("highs", time_limit=123) option_status, time_limit = highs_model.getOptionValue("time_limit") assert str(option_status) == "HighsStatus.kOk" @@ -108,13 +108,13 @@ def test_highs_to_solver_model_applies_solver_options(simple_model: Model) -> No "highs" not in set(solvers.available_solvers), reason="HiGHS is not installed" ) def test_solver_state_compatibility_setters(simple_model: Model) -> None: - simple_model.to_solver_model("highs") + simple_model.prepare_solver("highs") simple_model.solver_model = None assert simple_model.solver is None assert simple_model.solver_model is None assert simple_model.solver_name is None - simple_model.to_solver_model("highs") + simple_model.prepare_solver("highs") simple_model.solver_name = None assert simple_model.solver is None assert simple_model.solver_model is None From 3749f8f1f998ad54231fa2223369904c2bf8e102 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 13 May 2026 09:28:52 +0200 Subject: [PATCH 11/27] refactor: generalize runtime-conditional solver features Replace Xpress-specific _xpress_supports_gpu with a generic _installed_version_in helper, and add Solver.runtime_features() as an override hook for version/env-conditional capabilities. Xpress now declares its GPU support via runtime_features() instead of inline frozenset arithmetic on the class body. --- doc/release_notes.rst | 4 ++-- linopy/solver_capabilities.py | 11 ++++++----- linopy/solvers.py | 29 +++++++++++++++++++++-------- test/test_solvers.py | 6 ++++-- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 68c89a98..018307df 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,8 +4,8 @@ Release Notes Upcoming Version ---------------- -* Solver refactor: solver state now lives on a stateful ``Solver`` instance attached to ``Model.solver``. ``Model.solver_model`` and ``Model.solver_name`` become read-only properties delegating to ``model.solver`` (assigning anything other than ``None`` raises; setting ``None`` closes the solver). ``Model.solver_name`` may be ``None`` before a solve. -* Two-step solve workflow: ``Model.prepare_solver(solver_name, ...)`` builds the native solver model without solving, and ``Model.run_solver()`` runs it. ``Model.apply_result(result=None)`` exposes the solution-mapping step and defaults to the state on ``model.solver``. +* Solver refactor: solver state now lives on a stateful ``Solver`` instance attached to ``Model.solver``. ``Model.solver_model`` and ``Model.solver_name`` become read-only properties delegating to ``model.solver`` (assigning anything other than ``None`` raises; setting ``None`` closes the solver). ``Model.solver_name`` may be ``None`` before a solve. The latter two properties may be deprecated in future versions. +* Two-step solve workflow *for advanced users*: ``Model.prepare_solver(solver_name, ...)`` builds the native solver model without solving, and ``Model.run_solver()`` runs it. ``Model.apply_result(result=None)`` exposes the solution-mapping step and defaults to the state on ``model.solver``. Only use these methods if you want to control optimization on the solver instance directly. * Solver capabilities are declared as ``features: frozenset[SolverFeature]`` ClassVars on each ``Solver`` subclass; use ``Solver.supports(feature)``. ``SolverFeature`` is now exported from ``linopy`` (and from ``linopy.solvers``); ``linopy.solver_capabilities`` remains as a back-compat shim with a lazy ``SOLVER_REGISTRY`` mapping. * ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``. * Direct-API solvers (HiGHS, Gurobi, Mosek, cuPDLPx) cache variable/constraint labels on the ``Solver`` instance, so solutions are reconstructed by label rather than by solver-side names — fixes label mapping when names are not set on the solver. diff --git a/linopy/solver_capabilities.py b/linopy/solver_capabilities.py index 142ae521..0e748082 100644 --- a/linopy/solver_capabilities.py +++ b/linopy/solver_capabilities.py @@ -13,13 +13,12 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from linopy.solvers import Solver, SolverFeature, _xpress_supports_gpu + from linopy.solvers import Solver, SolverFeature __all__ = ( "SOLVER_REGISTRY", "SolverFeature", "SolverInfo", - "_xpress_supports_gpu", "get_available_solvers_with_feature", "get_solvers_with_feature", "solver_supports", @@ -27,10 +26,10 @@ def __getattr__(name: str) -> object: - if name in {"SolverFeature", "_xpress_supports_gpu"}: + if name == "SolverFeature": from linopy import solvers as _solvers_mod - return getattr(_solvers_mod, name) + return _solvers_mod.SolverFeature raise AttributeError(name) @@ -82,7 +81,9 @@ def __getitem__(self, key: str) -> SolverInfo: if cls is None: raise KeyError(key) return SolverInfo( - name=key, features=cls.features, display_name=cls.display_name + name=key, + features=cls.supported_features(), + display_name=cls.display_name, ) def __iter__(self) -> Iterator[str]: diff --git a/linopy/solvers.py b/linopy/solvers.py index 0184e75e..9a205361 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -56,10 +56,10 @@ class SolverFeature(Enum): SOLVER_ATTRIBUTE_ACCESS = auto() -def _xpress_supports_gpu() -> bool: - """Check if installed xpress version supports GPU acceleration (>=9.8.0).""" +def _installed_version_in(pkg: str, spec: str) -> bool: + """Check whether the installed version of `pkg` satisfies `spec`.""" try: - return package_version("xpress") in SpecifierSet(">=9.8.0") + return package_version(pkg) in SpecifierSet(spec) except PackageNotFoundError: return False @@ -328,10 +328,21 @@ class Solver(ABC, Generic[EnvType]): display_name: ClassVar[str] = "" features: ClassVar[frozenset[SolverFeature]] = frozenset() + @classmethod + def runtime_features(cls) -> frozenset[SolverFeature]: + """Features whose availability depends on the installed solver version + or runtime environment. Override in subclasses; the default is empty.""" + return frozenset() + + @classmethod + def supported_features(cls) -> frozenset[SolverFeature]: + """All features supported by this solver, static plus runtime.""" + return cls.features | cls.runtime_features() + @classmethod def supports(cls, feature: SolverFeature) -> bool: """Check if this solver supports a given feature.""" - return feature in cls.features + return feature in cls.features or feature in cls.runtime_features() def __init__( self, @@ -1835,12 +1846,14 @@ class Xpress(Solver[None]): SolverFeature.SOLUTION_FILE_NOT_NEEDED, SolverFeature.IIS_COMPUTATION, } - ) | ( - frozenset({SolverFeature.GPU_ACCELERATION}) - if _xpress_supports_gpu() - else frozenset() ) + @classmethod + def runtime_features(cls) -> frozenset[SolverFeature]: + if _installed_version_in("xpress", ">=9.8.0"): + return frozenset({SolverFeature.GPU_ACCELERATION}) + return frozenset() + def __init__( self, **solver_options: Any, diff --git a/test/test_solvers.py b/test/test_solvers.py index 9e1783b4..5d0fe7af 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -18,9 +18,9 @@ SOLVER_REGISTRY, SolverFeature, SolverInfo, - _xpress_supports_gpu, solver_supports, ) +from linopy.solvers import _installed_version_in @pytest.fixture @@ -422,4 +422,6 @@ def test_solver_registry_iter_and_index() -> None: "xpress" not in set(solvers.available_solvers), reason="Xpress is not installed" ) def test_xpress_gpu_feature_reflects_installed_version() -> None: - assert solvers.Xpress.supports(SolverFeature.GPU_ACCELERATION) == _xpress_supports_gpu() + assert solvers.Xpress.supports( + SolverFeature.GPU_ACCELERATION + ) == _installed_version_in("xpress", ">=9.8.0") From 118ca9f2c76ec962b0ff62ee0a20cf805ef24a59 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 13 May 2026 09:47:19 +0200 Subject: [PATCH 12/27] Delete piecewise-feasibility-tests-walkthrough.ipynb --- ...cewise-feasibility-tests-walkthrough.ipynb | 860 ------------------ 1 file changed, 860 deletions(-) delete mode 100644 dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb diff --git a/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb b/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb deleted file mode 100644 index 4c324018..00000000 --- a/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb +++ /dev/null @@ -1,860 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "ab725da0", - "metadata": {}, - "source": [ - "# Solver capability migration — walkthrough\n", - "\n", - "**Branch:** `solver-refac` \n", - "**Goal:** capability data lives on each `Solver` subclass as `ClassVar`s. `linopy.solver_capabilities` becomes a back-compat shim.\n", - "\n", - "What used to be duplicated between `solvers.py` (the classes) and `solver_capabilities.py` (`SOLVER_REGISTRY`) is now declared once, on the class itself. The registry is a lazy view over the classes.\n", - "\n", - "Sections:\n", - "1. Class-level API: `Solver.features`, `Solver.supports`, `Solver.display_name`\n", - "2. Back-compat shim — every legacy import still works\n", - "3. `SOLVER_REGISTRY` is now a lazy `Mapping` view\n", - "4. Xpress GPU feature is still version-gated\n", - "5. Internal callers (`Model.solve`) use the class API directly\n", - "\n", - "*Run from the repo root.*" - ] - }, - { - "cell_type": "markdown", - "id": "d92f1020", - "metadata": {}, - "source": [ - "## 1. Class-level API\n", - "\n", - "Each `Solver` subclass declares `features` and `display_name` as `ClassVar`s, and inherits a `supports()` classmethod from the base class." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "a8fd9c74", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-12T10:36:40.801067Z", - "iopub.status.busy": "2026-05-12T10:36:40.800695Z", - "iopub.status.idle": "2026-05-12T10:36:41.331745Z", - "shell.execute_reply": "2026-05-12T10:36:41.331426Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "[,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ]" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from linopy import SolverFeature # newly re-exported at the top level\n", - "from linopy import solvers\n", - "\n", - "list(SolverFeature)" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "b7756f8e", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-12T10:36:41.333374Z", - "iopub.status.busy": "2026-05-12T10:36:41.333113Z", - "iopub.status.idle": "2026-05-12T10:36:41.342647Z", - "shell.execute_reply": "2026-05-12T10:36:41.342386Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Gurobi\n", - "True\n", - "False\n", - "True\n" - ] - } - ], - "source": [ - "print(solvers.Gurobi.display_name)\n", - "print(solvers.Gurobi.supports(SolverFeature.SOS_CONSTRAINTS))\n", - "print(solvers.Highs.supports(SolverFeature.SOS_CONSTRAINTS))\n", - "print(solvers.cuPDLPx.supports(SolverFeature.GPU_ACCELERATION))" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "0403ecce", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-12T10:36:41.343840Z", - "iopub.status.busy": "2026-05-12T10:36:41.343725Z", - "iopub.status.idle": "2026-05-12T10:36:41.360408Z", - "shell.execute_reply": "2026-05-12T10:36:41.360126Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
INTEGER_VARIABLESQUADRATIC_OBJECTIVEDIRECT_APILP_FILE_NAMESREAD_MODEL_FROM_FILESOLUTION_FILE_NOT_NEEDEDGPU_ACCELERATIONIIS_COMPUTATIONSOS_CONSTRAINTSSEMI_CONTINUOUS_VARIABLESSOLVER_ATTRIBUTE_ACCESS
solver
cbcTrueFalseFalseFalseTrueFalseFalseFalseFalseFalseFalse
glpkTrueFalseFalseFalseTrueFalseFalseFalseFalseFalseFalse
highsTrueTrueTrueTrueTrueTrueFalseFalseFalseTrueFalse
cplexTrueTrueFalseTrueTrueFalseFalseFalseTrueTrueFalse
gurobiTrueTrueTrueTrueTrueTrueFalseTrueTrueTrueTrue
scipTrueTrueFalseTrueTrueTrueFalseFalseFalseFalseFalse
xpressTrueTrueFalseTrueTrueTrueFalseTrueFalseFalseFalse
knitroTrueTrueFalseTrueTrueTrueFalseFalseFalseFalseFalse
mosekTrueTrueTrueTrueTrueTrueFalseFalseFalseFalseFalse
coptTrueTrueFalseTrueTrueTrueFalseFalseFalseFalseFalse
mindoptTrueTrueFalseTrueTrueTrueFalseFalseFalseFalseFalse
pipsFalseFalseFalseFalseFalseFalseFalseFalseFalseFalseFalse
cupdlpxFalseFalseTrueFalseFalseTrueTrueFalseFalseFalseFalse
\n", - "
" - ], - "text/plain": [ - " INTEGER_VARIABLES QUADRATIC_OBJECTIVE DIRECT_API LP_FILE_NAMES \\\n", - "solver \n", - "cbc True False False False \n", - "glpk True False False False \n", - "highs True True True True \n", - "cplex True True False True \n", - "gurobi True True True True \n", - "scip True True False True \n", - "xpress True True False True \n", - "knitro True True False True \n", - "mosek True True True True \n", - "copt True True False True \n", - "mindopt True True False True \n", - "pips False False False False \n", - "cupdlpx False False True False \n", - "\n", - " READ_MODEL_FROM_FILE SOLUTION_FILE_NOT_NEEDED GPU_ACCELERATION \\\n", - "solver \n", - "cbc True False False \n", - "glpk True False False \n", - "highs True True False \n", - "cplex True False False \n", - "gurobi True True False \n", - "scip True True False \n", - "xpress True True False \n", - "knitro True True False \n", - "mosek True True False \n", - "copt True True False \n", - "mindopt True True False \n", - "pips False False False \n", - "cupdlpx False True True \n", - "\n", - " IIS_COMPUTATION SOS_CONSTRAINTS SEMI_CONTINUOUS_VARIABLES \\\n", - "solver \n", - "cbc False False False \n", - "glpk False False False \n", - "highs False False True \n", - "cplex False True True \n", - "gurobi True True True \n", - "scip False False False \n", - "xpress True False False \n", - "knitro False False False \n", - "mosek False False False \n", - "copt False False False \n", - "mindopt False False False \n", - "pips False False False \n", - "cupdlpx False False False \n", - "\n", - " SOLVER_ATTRIBUTE_ACCESS \n", - "solver \n", - "cbc False \n", - "glpk False \n", - "highs False \n", - "cplex False \n", - "gurobi True \n", - "scip False \n", - "xpress False \n", - "knitro False \n", - "mosek False \n", - "copt False \n", - "mindopt False \n", - "pips False \n", - "cupdlpx False " - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# A quick capability matrix across every Solver subclass.\n", - "import pandas as pd\n", - "\n", - "rows = []\n", - "for name in solvers.SolverName:\n", - " cls = getattr(solvers, name.name)\n", - " rows.append({\"solver\": name.value, **{f.name: cls.supports(f) for f in SolverFeature}})\n", - "pd.DataFrame(rows).set_index(\"solver\")" - ] - }, - { - "cell_type": "markdown", - "id": "e37d4778", - "metadata": {}, - "source": [ - "## 2. Back-compat shim\n", - "\n", - "Downstream packages (pypsa-eur, …) import from `linopy.solver_capabilities`. Every legacy symbol still resolves, but `solver_supports` / `get_solvers_with_feature` now delegate to the solver classes." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "cd259d73", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-12T10:36:41.361938Z", - "iopub.status.busy": "2026-05-12T10:36:41.361804Z", - "iopub.status.idle": "2026-05-12T10:36:41.371889Z", - "shell.execute_reply": "2026-05-12T10:36:41.371592Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n", - "['highs', 'gurobi', 'mosek', 'cupdlpx']\n", - "['highs', 'gurobi']\n" - ] - } - ], - "source": [ - "from linopy.solver_capabilities import (\n", - " SOLVER_REGISTRY,\n", - " SolverFeature as SF_shim,\n", - " SolverInfo,\n", - " get_available_solvers_with_feature,\n", - " get_solvers_with_feature,\n", - " solver_supports,\n", - ")\n", - "\n", - "# Same enum object — the shim re-exports from linopy.solvers.\n", - "assert SF_shim is SolverFeature\n", - "\n", - "print(solver_supports(\"gurobi\", SolverFeature.IIS_COMPUTATION))\n", - "print(get_solvers_with_feature(SolverFeature.DIRECT_API))\n", - "print(get_available_solvers_with_feature(SolverFeature.DIRECT_API, solvers.available_solvers))" - ] - }, - { - "cell_type": "markdown", - "id": "8cc6f939", - "metadata": {}, - "source": [ - "## 3. `SOLVER_REGISTRY` is a lazy Mapping\n", - "\n", - "Downstream code that does `for name in SOLVER_REGISTRY: info = SOLVER_REGISTRY[name]; ...` keeps working. `SolverInfo` is constructed on demand from the class declarations." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "f44e3093", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-12T10:36:41.373347Z", - "iopub.status.busy": "2026-05-12T10:36:41.373235Z", - "iopub.status.idle": "2026-05-12T10:36:41.383248Z", - "shell.execute_reply": "2026-05-12T10:36:41.382950Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "_LazyRegistry\n", - "13 solvers known\n", - "SolverInfo\n", - "gurobi · Gurobi\n", - "['DIRECT_API', 'IIS_COMPUTATION', 'INTEGER_VARIABLES', 'LP_FILE_NAMES', 'QUADRATIC_OBJECTIVE', 'READ_MODEL_FROM_FILE', 'SEMI_CONTINUOUS_VARIABLES', 'SOLUTION_FILE_NOT_NEEDED', 'SOLVER_ATTRIBUTE_ACCESS', 'SOS_CONSTRAINTS']\n" - ] - } - ], - "source": [ - "print(type(SOLVER_REGISTRY).__name__)\n", - "print(len(SOLVER_REGISTRY), \"solvers known\")\n", - "info = SOLVER_REGISTRY[\"gurobi\"]\n", - "print(type(info).__name__)\n", - "print(info.name, \"·\", info.display_name)\n", - "print(sorted(f.name for f in info.features))" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "776d220b", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-12T10:36:41.384574Z", - "iopub.status.busy": "2026-05-12T10:36:41.384463Z", - "iopub.status.idle": "2026-05-12T10:36:41.394339Z", - "shell.execute_reply": "2026-05-12T10:36:41.394052Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "shim and class API agree on all 143 pairs\n" - ] - } - ], - "source": [ - "# Round-trip property: shim ↔ class API agree for every (solver, feature) pair.\n", - "mismatches = [\n", - " (n.value, f.name)\n", - " for n in solvers.SolverName\n", - " for f in SolverFeature\n", - " if solver_supports(n.value, f) != getattr(solvers, n.name).supports(f)\n", - "]\n", - "assert mismatches == [], mismatches\n", - "print(\"shim and class API agree on all\", len(list(solvers.SolverName)) * len(list(SolverFeature)), \"pairs\")" - ] - }, - { - "cell_type": "markdown", - "id": "47ee876b", - "metadata": {}, - "source": [ - "## 4. Xpress GPU is still version-gated\n", - "\n", - "`_xpress_supports_gpu()` is now defined in `linopy.solvers` and evaluated once at class-definition time — same semantics as before, just colocated with the class it gates." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "4c74cd49", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-12T10:36:41.395592Z", - "iopub.status.busy": "2026-05-12T10:36:41.395493Z", - "iopub.status.idle": "2026-05-12T10:36:41.404649Z", - "shell.execute_reply": "2026-05-12T10:36:41.404392Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "xpress >= 9.8.0 installed? False\n", - "Xpress.supports(GPU_ACCELERATION): False\n" - ] - } - ], - "source": [ - "from linopy.solvers import _xpress_supports_gpu\n", - "\n", - "gpu = _xpress_supports_gpu()\n", - "print(\"xpress >= 9.8.0 installed?\", gpu)\n", - "print(\"Xpress.supports(GPU_ACCELERATION):\", solvers.Xpress.supports(SolverFeature.GPU_ACCELERATION))\n", - "assert solvers.Xpress.supports(SolverFeature.GPU_ACCELERATION) == gpu" - ] - }, - { - "cell_type": "markdown", - "id": "6131eb92", - "metadata": {}, - "source": [ - "## 5. Internal callers use the class API\n", - "\n", - "`Model.solve()` resolves `solver_class` once and then asks `solver_class.supports(...)` for every pre-instantiation check (quadratic objective, SOS constraints, semi-continuous vars, LP file names, solution-file-not-needed). `compute_infeasibilities()` queries `self.solver.supports(IIS_COMPUTATION)` on the already-instantiated solver.\n", - "\n", - "The only remaining string-keyed call (`solver_supports(name, ...)`) lives in `linopy/variables.py`, where a constraint-add path runs before a solver instance exists. It routes through the shim — no behavior change." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "1ac953f0", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-12T10:36:41.406074Z", - "iopub.status.busy": "2026-05-12T10:36:41.405878Z", - "iopub.status.idle": "2026-05-12T10:36:41.636394Z", - "shell.execute_reply": "2026-05-12T10:36:41.635958Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Restricted license - for non-production use only - expires 2027-11-29\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Read LP format model from file /tmp/linopy-problem-ft6epgcz.lp\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Reading time = 0.00 seconds\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "obj: 2 rows, 2 columns, 4 nonzeros\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Gurobi Optimizer version 13.0.2 build v13.0.2rc1 (linux64 - \"Ubuntu 24.04.4 LTS\")\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CPU model: Intel(R) Core(TM) Ultra 7 165U, instruction set [SSE2|AVX|AVX2]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Thread count: 14 physical cores, 14 logical processors, using up to 14 threads\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Optimize a model with 2 rows, 2 columns and 4 nonzeros (Min)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Model fingerprint: 0x364477a6\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Model has 2 linear objective coefficients\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Coefficient statistics:\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Matrix range [2e+00, 6e+00]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Objective range [1e+00, 2e+00]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Bounds range [0e+00, 0e+00]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " RHS range [3e+00, 1e+01]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Presolve removed 2 rows and 2 columns\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Presolve time: 0.00s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Presolve: All rows and columns removed\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Iteration Objective Primal Inf. Dual Inf. Time\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 0 3.3000000e+00 0.000000e+00 0.000000e+00 0s\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Solved in 0 iterations and 0.00 seconds (0.00 work units)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Optimal objective 3.300000000e+00\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "solver: Gurobi(name='gurobi', status='ok', io_api='lp', solver_model=loaded, env=active, objective=3.3, runtime=0.00437s)\n", - "supports IIS? True\n" - ] - } - ], - "source": [ - "# Quick end-to-end smoke: solve uses the new path, status is OK.\n", - "from linopy import GREATER_EQUAL, Model\n", - "\n", - "m = Model()\n", - "x = m.add_variables(name=\"x\")\n", - "y = m.add_variables(name=\"y\")\n", - "m.add_constraints(2 * x + 6 * y, GREATER_EQUAL, 10)\n", - "m.add_constraints(4 * x + 2 * y, GREATER_EQUAL, 3)\n", - "m.add_objective(2 * y + x)\n", - "m.solve(solvers.available_solvers[0])\n", - "\n", - "print(\"solver:\", m.solver)\n", - "print(\"supports IIS?\", m.solver.supports(SolverFeature.IIS_COMPUTATION))" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From 4ab3ddc11372c695b5ebb751fa206964a87ef4ab Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 13 May 2026 10:13:49 +0200 Subject: [PATCH 13/27] refactor: consolidate solve_problem_from_model docstring on abstract method Move the full parameter docstring onto Solver.solve_problem_from_model and drop the per-subclass duplicates on Mosek and cuPDLPx; subclasses now inherit the abstract method's docstring. --- linopy/solvers.py | 91 +++++++++++++++-------------------------------- 1 file changed, 29 insertions(+), 62 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 9a205361..4648de58 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -465,11 +465,36 @@ def solve_problem_from_model( set_names: bool = True, ) -> Result: """ - Abstract method to solve a linear problem from a model. + Solve a linear problem directly from a linopy model. - Needs to be implemented in the specific solver subclass. Even if the solver - does not support solving from a model, this method should be implemented and - raise a NotImplementedError. + Subclasses that support the direct API translate the model into the + solver's native representation and run it. Subclasses without direct + API support must still implement this method and raise NotImplementedError. + + Parameters + ---------- + model : linopy.Model + Linopy model for the problem. + solution_fn : Path, optional + Path to the solution file. + log_fn : Path, optional + Path to the log file. + warmstart_fn : Path, optional + Path to the warmstart file. + basis_fn : Path, optional + Path to the basis file. + env : EnvType, optional + Solver-specific environment object (or None when not applicable). + explicit_coordinate_names : bool, optional + Transfer variable and constraint coordinate names to the solver + (default: False). + set_names : bool, optional + Whether to set variable and constraint names (default: True). + Setting to False can significantly speed up model export. + + Returns + ------- + Result """ pass @@ -2334,35 +2359,6 @@ def solve_problem_from_model( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> Result: - """ - Solve a linear problem directly from a linopy model using the MOSEK solver. - - Parameters - ---------- - model : linopy.model - Linopy model for the problem. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional, deprecated - Deprecated. This parameter is ignored. MOSEK now uses the global - environment automatically. Will be removed in a future version. - explicit_coordinate_names : bool, optional - Transfer variable and constraint names to the solver (default: False) - set_names : bool, optional - Whether to set variable and constraint names (default: True). - Setting to False can significantly speed up model export. - - Returns - ------- - Result - """ - if env is not None: warnings.warn( "The 'env' parameter in solve_problem_from_model is deprecated and will be " @@ -3112,35 +3108,6 @@ def solve_problem_from_model( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> Result: - """ - Solve a linear problem directly from a linopy model using the solver cuPDLPx. - If the solution is feasible the function returns the - objective, solution and dual constraint variables. - - Parameters - ---------- - model : linopy.model - Linopy model for the problem. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - Environment for the solver - explicit_coordinate_names : bool, optional - Transfer variable and constraint names to the solver (default: False) - set_names : bool, optional - Ignored. cuPDLPx does not support named variables/constraints. - - Returns - ------- - Result - """ - self.to_solver_model(model) return self._solve( From 67d57432772587d626d0064c86fefb3361ced804 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 13 May 2026 11:19:36 +0200 Subject: [PATCH 14/27] refactor: rename solver translators to _build_solver_model Unify per-solver _translate_to_* methods under a common _build_solver_model name, hoist their local imports to module top-level, drop dead params from cuPDLPx (moving its UserWarning into the public to_solver_model), and add TYPE_CHECKING stubs. Expand to_* deprecation messages with step-by-step migration paths, wrap existing tests in pytest.warns, and cover the unknown-solver-name branch in prepare_solver. --- doc/release_notes.rst | 1 + linopy/io.py | 416 ++++++----------------------------- linopy/model.py | 4 +- linopy/solvers.py | 285 +++++++++++++++++++++++- test/test_io.py | 28 ++- test/test_solvers.py | 5 + test/test_sos_constraints.py | 12 +- 7 files changed, 385 insertions(+), 366 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 018307df..60490057 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -9,6 +9,7 @@ Upcoming Version * Solver capabilities are declared as ``features: frozenset[SolverFeature]`` ClassVars on each ``Solver`` subclass; use ``Solver.supports(feature)``. ``SolverFeature`` is now exported from ``linopy`` (and from ``linopy.solvers``); ``linopy.solver_capabilities`` remains as a back-compat shim with a lazy ``SOLVER_REGISTRY`` mapping. * ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``. * Direct-API solvers (HiGHS, Gurobi, Mosek, cuPDLPx) cache variable/constraint labels on the ``Solver`` instance, so solutions are reconstructed by label rather than by solver-side names — fixes label mapping when names are not set on the solver. +* Per-solver translation helpers are unified under ``Solver._build_solver_model`` . The module-level ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` / ``to_cupdlpx`` (and their ``Model`` bindings) now emit ``DeprecationWarning`` with step-by-step migration to ``Solver.to_solver_model(model)`` or ``Model.prepare_solver(solver_name)``. * Increase speed of direct solver communication (~10x) in conversion functions like `to_highspy` through faster matrix creation (see below), leading to significant overall speed-up when setting `io_api="direct"`. * Add ``CSRConstraint``, a memory-efficient immutable constraint representation backed by scipy CSR sparse matrices. Provides up to 90% memory savings for constraints with many terms and 30–120x faster matrix generation for direct solver APIs. - Add ``freeze_constraints`` parameter to ``Model`` for globally storing constraints in CSR format on ``add_constraints``. diff --git a/linopy/io.py b/linopy/io.py index 6dc1c9c9..1649a659 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -20,8 +20,6 @@ import pandas as pd import polars as pl import xarray as xr -from numpy import ones_like, zeros_like -from scipy.sparse import tril, triu from tqdm import tqdm from linopy import solvers @@ -625,7 +623,9 @@ def to_file( # Use very fast highspy implementation # Might be replaced by custom writer, however needs C/Rust bindings for performance - h = m.to_highspy(explicit_coordinate_names=explicit_coordinate_names) + h = solvers.Highs._build_solver_model( + m, explicit_coordinate_names=explicit_coordinate_names + ) h.writeModel(str(fn)) else: raise ValueError( @@ -641,125 +641,27 @@ def to_mosek( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> Any: - """ - Export model to MOSEK. - - Export the model directly to MOSEK without writing files. - - Parameters - ---------- - m : linopy.Model - task : empty MOSEK task - explicit_coordinate_names : bool, optional - Whether to use explicit coordinate names. Default is False. - set_names : bool, optional - Whether to set variable and constraint names. Default is True. - Setting to False can significantly speed up model export. - - Returns - ------- - task : MOSEK Task object - """ - if m.variables.sos: - raise NotImplementedError("SOS constraints are not supported by MOSEK.") - - if m.variables.semi_continuous: - raise NotImplementedError( - "Semi-continuous variables are not supported by MOSEK. " - "Use a solver that supports them (gurobi, cplex, highs)." - ) - + """Deprecated. Build the MOSEK task via ``Mosek`` or ``Model.prepare_solver``.""" + warnings.warn( + "to_mosek is deprecated and will be removed in a future version. " + "To obtain the MOSEK task, either:\n" + " 1) solver = linopy.solvers.Mosek(); " + "task = solver.to_solver_model(model); " + "or\n" + " 2) task = model.prepare_solver('mosek').", + DeprecationWarning, + stacklevel=2, + ) import mosek if task is None: task = mosek.Task() - - task.appendvars(m.nvars) - task.appendcons(m.ncons) - - M = m.matrices - - if set_names: - print_variables, print_constraints = get_printers_scalar( - m, explicit_coordinate_names=explicit_coordinate_names - ) - labels = print_variables(M.vlabels) - task.generatevarnames( - np.arange(0, len(labels)), "%0", [len(labels)], None, [0], labels - ) - - ## Variables - - # MOSEK uses bound keys (free, bounded below or above, ranged and fixed) - # plus bound values (lower and upper), and it is considered an error to - # input an infinite value for a finite bound. - # bkx and bkc define the boundkeys based on upper and lower bound, and blx, - # bux, blc and buc define the finite bounds. The numerical value of a bound - # indicated to be infinite by the bound key is ignored by MOSEK. - bkx = [ - ( - ( - (mosek.boundkey.ra if lb < ub else mosek.boundkey.fx) - if ub < np.inf - else mosek.boundkey.lo - ) - if (lb > -np.inf) - else (mosek.boundkey.up if (ub < np.inf) else mosek.boundkey.fr) - ) - for (lb, ub) in zip(M.lb, M.ub) - ] - blx = [b if b > -np.inf else 0.0 for b in M.lb] - bux = [b if b < np.inf else 0.0 for b in M.ub] - task.putvarboundslice(0, m.nvars, bkx, blx, bux) - - if len(m.binaries.labels) + len(m.integers.labels) > 0: - idx = [i for (i, v) in enumerate(M.vtypes) if v in ["B", "I"]] - task.putvartypelist(idx, [mosek.variabletype.type_int] * len(idx)) - if len(m.binaries.labels) > 0: - bidx = [i for (i, v) in enumerate(M.vtypes) if v == "B"] - task.putvarboundlistconst(bidx, mosek.boundkey.ra, 0.0, 1.0) - - ## Constraints - - if len(m.constraints) > 0: - if set_names: - names = print_constraints(M.clabels) - for i, n in enumerate(names): - task.putconname(i, n) - bkc = [ - ( - (mosek.boundkey.up if b < np.inf else mosek.boundkey.fr) - if s == "<" - else ( - (mosek.boundkey.lo if b > -np.inf else mosek.boundkey.up) - if s == ">" - else mosek.boundkey.fx - ) - ) - for s, b in zip(M.sense, M.b) - ] - blc = [b if b > -np.inf else 0.0 for b in M.b] - buc = [b if b < np.inf else 0.0 for b in M.b] - # blc = M.b - # buc = M.b - if M.A is not None: - A = M.A.tocsr() - task.putarowslice( - 0, m.ncons, A.indptr[:-1], A.indptr[1:], A.indices, A.data - ) - task.putconboundslice(0, m.ncons, bkc, blc, buc) - - ## Objective - if M.Q is not None: - Q = (0.5 * tril(M.Q + M.Q.transpose())).tocoo() - task.putqobj(Q.row, Q.col, Q.data) - task.putclist(list(np.arange(m.nvars)), M.c) - - if m.objective.sense == "max": - task.putobjsense(mosek.objsense.maximize) - else: - task.putobjsense(mosek.objsense.minimize) - return task + return solvers.Mosek._build_solver_model( + m, + task, + explicit_coordinate_names=explicit_coordinate_names, + set_names=set_names, + ) def to_gurobipy( @@ -768,84 +670,23 @@ def to_gurobipy( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> Any: - """ - Export the model to gurobipy. - - This function does not write the model to intermediate files but directly - passes it to gurobipy. Note that for large models this is not - computationally efficient. - - Parameters - ---------- - m : linopy.Model - env : gurobipy.Env - explicit_coordinate_names : bool, optional - Whether to use explicit coordinate names. Default is False. - set_names : bool, optional - Whether to set variable and constraint names. Default is True. - Setting to False can significantly speed up model export. - - Returns - ------- - model : gurobipy.Model - """ - import gurobipy - - m.constraints.sanitize_missings() - model = gurobipy.Model(env=env) - - M = m.matrices - - kwargs = {} - if set_names: - print_variables, print_constraints = get_printers_scalar( - m, explicit_coordinate_names=explicit_coordinate_names - ) - kwargs["name"] = print_variables(M.vlabels) - if ( - len(m.binaries.labels) - + len(m.integers.labels) - + len(list(m.variables.semi_continuous)) - ): - kwargs["vtype"] = M.vtypes - x = model.addMVar(M.vlabels.shape, M.lb, M.ub, **kwargs) - - if m.is_quadratic: - model.setObjective(0.5 * x.T @ M.Q @ x + M.c @ x) # type: ignore - else: - model.setObjective(M.c @ x) - - if m.objective.sense == "max": - model.ModelSense = -1 - - if len(m.constraints): - c = model.addMConstr(M.A, x, M.sense, M.b) # type: ignore - if set_names: - names = print_constraints(M.clabels) - c.setAttr("ConstrName", names) - - if m.variables.sos: - for var_name in m.variables.sos: - var = m.variables.sos[var_name] - sos_type: int = var.attrs[SOS_TYPE_ATTR] # type: ignore[assignment] - sos_dim: str = var.attrs[SOS_DIM_ATTR] # type: ignore[assignment] - - def add_sos(s: xr.DataArray, sos_type: int, sos_dim: str) -> None: - s = s.squeeze() - indices = s.values.flatten().tolist() - weights = s.coords[sos_dim].values.tolist() - model.addSOS(sos_type, x[indices].tolist(), weights) - - others = [dim for dim in var.labels.dims if dim != sos_dim] - if not others: - add_sos(var.labels, sos_type, sos_dim) - else: - stacked = var.labels.stack(_sos_group=others) - for _, s in stacked.groupby("_sos_group"): - add_sos(s.unstack("_sos_group"), sos_type, sos_dim) - - model.update() - return model + """Deprecated. Build the gurobipy model via ``Gurobi`` or ``Model.prepare_solver``.""" + warnings.warn( + "to_gurobipy is deprecated and will be removed in a future version. " + "To obtain the gurobipy.Model, either:\n" + " 1) solver = linopy.solvers.Gurobi(); " + "gm = solver.to_solver_model(model, env=env); " + "or\n" + " 2) gm = model.prepare_solver('gurobi', env=env).", + DeprecationWarning, + stacklevel=2, + ) + return solvers.Gurobi._build_solver_model( + m, + env=env, + explicit_coordinate_names=explicit_coordinate_names, + set_names=set_names, + ) def to_highspy( @@ -853,166 +694,37 @@ def to_highspy( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> Highs: - """ - Export the model to highspy. - - This function does not write the model to intermediate files but directly - passes it to highspy. - - Parameters - ---------- - m : linopy.Model - explicit_coordinate_names : bool, optional - Whether to use explicit coordinate names. Default is False. - set_names : bool, optional - Whether to set variable and constraint names. Default is True. - Setting to False can significantly speed up model export. - - Returns - ------- - model : highspy.Highs - """ - if m.variables.sos: - raise NotImplementedError( - "SOS constraints are not supported by the HiGHS direct API. " - "Use io_api='lp' instead." - ) - - import highspy - - M = m.matrices - h = highspy.Highs() - h.addVars(len(M.vlabels), M.lb, M.ub) - if len(m.binaries) + len(m.integers) + len(list(m.variables.semi_continuous)): - vtypes = M.vtypes - # Map linopy vtypes to HiGHS integrality values: - # 0 = continuous, 1 = integer, 2 = semi-continuous - integrality_map = {"C": 0, "B": 1, "I": 1, "S": 2} - int_mask = (vtypes == "B") | (vtypes == "I") | (vtypes == "S") - labels = np.arange(len(vtypes))[int_mask] - integrality = np.array( - [integrality_map[v] for v in vtypes[int_mask]], dtype=np.int32 - ) - h.changeColsIntegrality(len(labels), labels, integrality) - if len(m.binaries): - labels = np.arange(len(vtypes))[vtypes == "B"] - n = len(labels) - h.changeColsBounds(n, labels, zeros_like(labels), ones_like(labels)) - - # linear objective - c = M.c - h.changeColsCost(len(c), np.arange(len(c), dtype=np.int32), c) - - # linear constraints - A = M.A - if A is not None: - A = A.tocsr() - num_cons = A.shape[0] - lower = np.where(M.sense != "<", M.b, -np.inf) - upper = np.where(M.sense != ">", M.b, np.inf) - h.addRows(num_cons, lower, upper, A.nnz, A.indptr, A.indices, A.data) - - if set_names: - print_variables, print_constraints = get_printers_scalar( - m, explicit_coordinate_names=explicit_coordinate_names - ) - lp = h.getLp() - lp.col_names_ = print_variables(M.vlabels) - if len(M.clabels): - lp.row_names_ = print_constraints(M.clabels) - h.passModel(lp) - - # quadrative objective - Q = M.Q - if Q is not None: - Q = triu(Q) - Q = Q.tocsr() - num_vars = Q.shape[0] - h.passHessian(num_vars, Q.nnz, 1, Q.indptr, Q.indices, Q.data) - - # change objective sense - if m.objective.sense == "max": - h.changeObjectiveSense(highspy.ObjSense.kMaximize) - - return h - - -def to_cupdlpx( - m: Model, - explicit_coordinate_names: bool = False, - set_names: bool = True, -) -> cupdlpxModel: - """ - Export the model to cupdlpx. - - This function does not write the model to intermediate files but directly - passes it to cupdlpx. - - cuPDLPx does not support named variables and constraints, so the - `explicit_coordinate_names` and `set_names` parameters are ignored. - - Parameters - ---------- - m : linopy.Model - explicit_coordinate_names : bool, optional - Ignored. cuPDLPx does not support named variables/constraints. - set_names : bool, optional - Ignored. cuPDLPx does not support named variables/constraints. - - Returns - ------- - model : cupdlpx.Model - """ - if m.variables.semi_continuous: - raise NotImplementedError( - "Semi-continuous variables are not supported by cuPDLPx. " - "Use a solver that supports them (gurobi, cplex, highs)." - ) - - import cupdlpx - - if explicit_coordinate_names: - warnings.warn( - "cuPDLPx does not support named variables/constraints. " - "The explicit_coordinate_names parameter is ignored.", - UserWarning, - stacklevel=2, - ) - - # build model using canonical form matrices and vectors - # see https://github.com/MIT-Lu-Lab/cuPDLPx/tree/main/python#modeling - M = m.matrices - if M.A is None: - msg = "Model has no constraints, cannot export to cuPDLPx." - raise ValueError(msg) - A = M.A.tocsr() # cuPDLPx only supports CSR sparse matrix format - # linopy stores constraints as Ax ?= b and keeps track of inequality - # sense in M.sense. Convert to separate lower and upper bound vectors. - l = np.where( - np.logical_or(np.equal(M.sense, ">"), np.equal(M.sense, "=")), - M.b, - -np.inf, + """Deprecated. Build the highspy model via ``Highs`` or ``Model.prepare_solver``.""" + warnings.warn( + "to_highspy is deprecated and will be removed in a future version. " + "To obtain the highspy.Highs instance, either:\n" + " 1) solver = linopy.solvers.Highs(); " + "h = solver.to_solver_model(model); " + "or\n" + " 2) h = model.prepare_solver('highs').", + DeprecationWarning, + stacklevel=2, ) - u = np.where( - np.logical_or(np.equal(M.sense, "<"), np.equal(M.sense, "=")), - M.b, - np.inf, + return solvers.Highs._build_solver_model( + m, + explicit_coordinate_names=explicit_coordinate_names, + set_names=set_names, ) - cu_model = cupdlpx.Model( - objective_vector=M.c, - constraint_matrix=A, - constraint_lower_bound=l, - constraint_upper_bound=u, - variable_lower_bound=M.lb, - variable_upper_bound=M.ub, - ) - - # change objective sense - if m.objective.sense == "max": - cu_model.ModelSense = cupdlpx.PDLP.MAXIMIZE - return cu_model +def to_cupdlpx(m: Model) -> cupdlpxModel: + """Deprecated. Build the cupdlpx model via ``cuPDLPx`` or ``Model.prepare_solver``.""" + warnings.warn( + "to_cupdlpx is deprecated and will be removed in a future version. " + "To obtain the cupdlpx.Model, either:\n" + " 1) solver = linopy.solvers.cuPDLPx(); " + "cu = solver.to_solver_model(model); " + "or\n" + " 2) cu = model.prepare_solver('cupdlpx').", + DeprecationWarning, + stacklevel=2, + ) + return solvers.cuPDLPx._build_solver_model(m) def to_block_files(m: Model, fn: Path) -> None: diff --git a/linopy/model.py b/linopy/model.py index af2da283..2767cd84 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1800,7 +1800,9 @@ def prepare_solver( the native model object (e.g. `gurobipy.Model`). Pair with `run_solver()` for a two-step build-then-solve workflow. """ - solver_class = getattr(solvers, solvers.SolverName(solver_name).name) + solver_class = solvers._solver_class_for(solver_name) + if solver_class is None: + raise ValueError(f"Unknown solver name: {solver_name}") if not solver_class.supports(SolverFeature.DIRECT_API): raise NotImplementedError( f"Solver {solver_name} does not support direct API model export." diff --git a/linopy/solvers.py b/linopy/solvers.py index 4648de58..e1812d04 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -26,11 +26,15 @@ import numpy as np import pandas as pd +import xarray as xr from packaging.specifiers import SpecifierSet from packaging.version import parse as parse_version +from scipy.sparse import tril, triu import linopy.io from linopy.constants import ( + SOS_DIM_ATTR, + SOS_TYPE_ATTR, Result, Solution, SolverReport, @@ -65,7 +69,10 @@ def _installed_version_in(pkg: str, spec: str) -> bool: if TYPE_CHECKING: + import cupdlpx import gurobipy + import highspy + import mosek from linopy.model import Model @@ -1008,7 +1015,8 @@ def to_solver_model( "Drop the solver option or use 'choose' to enable quadratic terms / integrality." ) - h = model.to_highspy( + h = self._build_solver_model( + model, explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, ) @@ -1019,6 +1027,74 @@ def to_solver_model( self.sense = model.sense return h + @staticmethod + def _build_solver_model( + model: Model, + explicit_coordinate_names: bool = False, + set_names: bool = True, + ) -> highspy.Highs: + """Build a highspy.Highs instance that mirrors the linopy `model`.""" + if model.variables.sos: + raise NotImplementedError( + "SOS constraints are not supported by the HiGHS direct API. " + "Use io_api='lp' instead." + ) + + M = model.matrices + h = highspy.Highs() + h.addVars(len(M.vlabels), M.lb, M.ub) + if ( + len(model.binaries) + + len(model.integers) + + len(list(model.variables.semi_continuous)) + ): + vtypes = M.vtypes + integrality_map = {"C": 0, "B": 1, "I": 1, "S": 2} + int_mask = (vtypes == "B") | (vtypes == "I") | (vtypes == "S") + labels = np.arange(len(vtypes))[int_mask] + integrality = np.array( + [integrality_map[v] for v in vtypes[int_mask]], dtype=np.int32 + ) + h.changeColsIntegrality(len(labels), labels, integrality) + if len(model.binaries): + labels = np.arange(len(vtypes))[vtypes == "B"] + n = len(labels) + h.changeColsBounds( + n, labels, np.zeros_like(labels), np.ones_like(labels) + ) + + c = M.c + h.changeColsCost(len(c), np.arange(len(c), dtype=np.int32), c) + + A = M.A + if A is not None: + A = A.tocsr() + num_cons = A.shape[0] + lower = np.where(M.sense != "<", M.b, -np.inf) + upper = np.where(M.sense != ">", M.b, np.inf) + h.addRows(num_cons, lower, upper, A.nnz, A.indptr, A.indices, A.data) + + if set_names: + print_variables, print_constraints = linopy.io.get_printers_scalar( + model, explicit_coordinate_names=explicit_coordinate_names + ) + lp = h.getLp() + lp.col_names_ = print_variables(M.vlabels) + if len(M.clabels): + lp.row_names_ = print_constraints(M.clabels) + h.passModel(lp) + + Q = M.Q + if Q is not None: + Q = triu(Q).tocsr() + num_vars = Q.shape[0] + h.passHessian(num_vars, Q.nnz, 1, Q.indptr, Q.indices, Q.data) + + if model.objective.sense == "max": + h.changeObjectiveSense(highspy.ObjSense.kMaximize) + + return h + def solve_problem_from_model( self, model: Model, @@ -1286,7 +1362,8 @@ def to_solver_model( **kwargs: Any, ) -> gurobipy.Model: env_ = self._resolve_env(env) - m = model.to_gurobipy( + m = self._build_solver_model( + model, env=env_, explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, @@ -1297,6 +1374,70 @@ def to_solver_model( self.sense = model.sense return m + @staticmethod + def _build_solver_model( + model: Model, + env: gurobipy.Env | None = None, + explicit_coordinate_names: bool = False, + set_names: bool = True, + ) -> gurobipy.Model: + """Build a gurobipy.Model that mirrors the linopy `model`.""" + model.constraints.sanitize_missings() + gm = gurobipy.Model(env=env) + + M = model.matrices + + kwargs: dict[str, Any] = {} + if set_names: + print_variables, print_constraints = linopy.io.get_printers_scalar( + model, explicit_coordinate_names=explicit_coordinate_names + ) + kwargs["name"] = print_variables(M.vlabels) + if ( + len(model.binaries.labels) + + len(model.integers.labels) + + len(list(model.variables.semi_continuous)) + ): + kwargs["vtype"] = M.vtypes + x = gm.addMVar(M.vlabels.shape, M.lb, M.ub, **kwargs) + + if model.is_quadratic: + gm.setObjective(0.5 * x.T @ M.Q @ x + M.c @ x) + else: + gm.setObjective(M.c @ x) + + if model.objective.sense == "max": + gm.ModelSense = -1 + + if len(model.constraints): + c = gm.addMConstr(M.A, x, M.sense, M.b) + if set_names: + names = print_constraints(M.clabels) + c.setAttr("ConstrName", names) + + if model.variables.sos: + for var_name in model.variables.sos: + var = model.variables.sos[var_name] + sos_type: int = var.attrs[SOS_TYPE_ATTR] + sos_dim: str = var.attrs[SOS_DIM_ATTR] + + def add_sos(s: xr.DataArray, sos_type: int, sos_dim: str) -> None: + s = s.squeeze() + indices = s.values.flatten().tolist() + weights = s.coords[sos_dim].values.tolist() + gm.addSOS(sos_type, x[indices].tolist(), weights) + + others = [dim for dim in var.labels.dims if dim != sos_dim] + if not others: + add_sos(var.labels, sos_type, sos_dim) + else: + stacked = var.labels.stack(_sos_group=others) + for _, s in stacked.groupby("_sos_group"): + add_sos(s.unstack("_sos_group"), sos_type, sos_dim) + + gm.update() + return gm + def solve_problem_from_model( self, model: Model, @@ -2403,7 +2544,8 @@ def to_solver_model( self.close() self._env_stack = contextlib.ExitStack() task = self._env_stack.enter_context(mosek.Task()) - m = model.to_mosek( + m = self._build_solver_model( + model, task, explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, @@ -2414,6 +2556,96 @@ def to_solver_model( self.sense = model.sense return m + @staticmethod + def _build_solver_model( + model: Model, + task: mosek.Task, + explicit_coordinate_names: bool = False, + set_names: bool = True, + ) -> mosek.Task: + """Populate an empty MOSEK task with the contents of `model`.""" + if model.variables.sos: + raise NotImplementedError("SOS constraints are not supported by MOSEK.") + if model.variables.semi_continuous: + raise NotImplementedError( + "Semi-continuous variables are not supported by MOSEK. " + "Use a solver that supports them (gurobi, cplex, highs)." + ) + + task.appendvars(model.nvars) + task.appendcons(model.ncons) + + M = model.matrices + + if set_names: + print_variables, print_constraints = linopy.io.get_printers_scalar( + model, explicit_coordinate_names=explicit_coordinate_names + ) + labels = print_variables(M.vlabels) + task.generatevarnames( + np.arange(0, len(labels)), "%0", [len(labels)], None, [0], labels + ) + + bkx = [ + ( + ( + (mosek.boundkey.ra if lb < ub else mosek.boundkey.fx) + if ub < np.inf + else mosek.boundkey.lo + ) + if (lb > -np.inf) + else (mosek.boundkey.up if (ub < np.inf) else mosek.boundkey.fr) + ) + for (lb, ub) in zip(M.lb, M.ub) + ] + blx = [b if b > -np.inf else 0.0 for b in M.lb] + bux = [b if b < np.inf else 0.0 for b in M.ub] + task.putvarboundslice(0, model.nvars, bkx, blx, bux) + + if len(model.binaries.labels) + len(model.integers.labels) > 0: + idx = [i for (i, v) in enumerate(M.vtypes) if v in ["B", "I"]] + task.putvartypelist(idx, [mosek.variabletype.type_int] * len(idx)) + if len(model.binaries.labels) > 0: + bidx = [i for (i, v) in enumerate(M.vtypes) if v == "B"] + task.putvarboundlistconst(bidx, mosek.boundkey.ra, 0.0, 1.0) + + if len(model.constraints) > 0: + if set_names: + names = print_constraints(M.clabels) + for i, n in enumerate(names): + task.putconname(i, n) + bkc = [ + ( + (mosek.boundkey.up if b < np.inf else mosek.boundkey.fr) + if s == "<" + else ( + (mosek.boundkey.lo if b > -np.inf else mosek.boundkey.up) + if s == ">" + else mosek.boundkey.fx + ) + ) + for s, b in zip(M.sense, M.b) + ] + blc = [b if b > -np.inf else 0.0 for b in M.b] + buc = [b if b < np.inf else 0.0 for b in M.b] + if M.A is not None: + A = M.A.tocsr() + task.putarowslice( + 0, model.ncons, A.indptr[:-1], A.indptr[1:], A.indices, A.data + ) + task.putconboundslice(0, model.ncons, bkc, blc, buc) + + if M.Q is not None: + Q = (0.5 * tril(M.Q + M.Q.transpose())).tocoo() + task.putqobj(Q.row, Q.col, Q.data) + task.putclist(list(np.arange(model.nvars)), M.c) + + if model.objective.sense == "max": + task.putobjsense(mosek.objsense.maximize) + else: + task.putobjsense(mosek.objsense.minimize) + return task + def solve_problem_from_file( self, problem_fn: Path, @@ -3125,13 +3357,58 @@ def to_solver_model(self, model: Model, **kwargs: Any) -> cupdlpx.Model: if model.type in ["QP", "MILP"]: msg = "cuPDLPx does not currently support QP or MILP problems." raise NotImplementedError(msg) - cu_model = model.to_cupdlpx() + if kwargs.get("explicit_coordinate_names"): + warnings.warn( + "cuPDLPx does not support named variables/constraints. " + "The explicit_coordinate_names parameter is ignored.", + UserWarning, + stacklevel=2, + ) + cu_model = self._build_solver_model(model) self._store_labels(model) self.solver_model = cu_model self.io_api = "direct" self.sense = model.sense return cu_model + @staticmethod + def _build_solver_model(model: Model) -> cupdlpx.Model: + """Build a cupdlpx.Model that mirrors the linopy `model`.""" + if model.variables.semi_continuous: + raise NotImplementedError( + "Semi-continuous variables are not supported by cuPDLPx. " + "Use a solver that supports them (gurobi, cplex, highs)." + ) + + M = model.matrices + if M.A is None: + raise ValueError("Model has no constraints, cannot export to cuPDLPx.") + A = M.A.tocsr() + lower = np.where( + np.logical_or(np.equal(M.sense, ">"), np.equal(M.sense, "=")), + M.b, + -np.inf, + ) + upper = np.where( + np.logical_or(np.equal(M.sense, "<"), np.equal(M.sense, "=")), + M.b, + np.inf, + ) + + cu_model = cupdlpx.Model( + objective_vector=M.c, + constraint_matrix=A, + constraint_lower_bound=lower, + constraint_upper_bound=upper, + variable_lower_bound=M.lb, + variable_upper_bound=M.ub, + ) + + if model.objective.sense == "max": + cu_model.ModelSense = cupdlpx.PDLP.MAXIMIZE + + return cu_model + def _run(self) -> Result: return self._solve( self.solver_model, diff --git a/test/test_io.py b/test/test_io.py index 0a4c4e64..55c1149b 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -274,13 +274,15 @@ def test_to_file_invalid(model: Model, tmp_path: Path) -> None: @pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") def test_to_gurobipy(model: Model) -> None: - model.to_gurobipy() + with pytest.warns(DeprecationWarning, match="to_gurobipy is deprecated"): + model.to_gurobipy() @pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") def test_to_gurobipy_no_names(model: Model) -> None: - m_with = model.to_gurobipy(set_names=True) - m_without = model.to_gurobipy(set_names=False) + with pytest.warns(DeprecationWarning, match="to_gurobipy is deprecated"): + m_with = model.to_gurobipy(set_names=True) + m_without = model.to_gurobipy(set_names=False) names_with = [v.VarName for v in m_with.getVars()] names_without = [v.VarName for v in m_without.getVars()] assert names_with != names_without @@ -288,17 +290,33 @@ def test_to_gurobipy_no_names(model: Model) -> None: @pytest.mark.skipif("highs" not in available_solvers, reason="Highspy not installed") def test_to_highspy(model: Model) -> None: - model.to_highspy() + with pytest.warns(DeprecationWarning, match="to_highspy is deprecated"): + model.to_highspy() @pytest.mark.skipif("highs" not in available_solvers, reason="Highspy not installed") def test_to_highspy_no_names(model: Model) -> None: - h = model.to_highspy(set_names=False) + with pytest.warns(DeprecationWarning, match="to_highspy is deprecated"): + h = model.to_highspy(set_names=False) lp = h.getLp() assert len(lp.col_names_) == 0 assert len(lp.row_names_) == 0 +@pytest.mark.skipif("mosek" not in available_solvers, reason="Mosek not installed") +def test_to_mosek_deprecation_warning(model: Model) -> None: + with pytest.warns(DeprecationWarning, match="to_mosek is deprecated"): + model.to_mosek() + + +@pytest.mark.skipif( + "cupdlpx" not in available_solvers, reason="cuPDLPx not installed" +) +def test_to_cupdlpx_deprecation_warning(model: Model) -> None: + with pytest.warns(DeprecationWarning, match="to_cupdlpx is deprecated"): + model.to_cupdlpx() + + def test_model_set_names_in_solver_io_default() -> None: assert Model().set_names_in_solver_io is True diff --git a/test/test_solvers.py b/test/test_solvers.py index 5d0fe7af..1a80e860 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -93,6 +93,11 @@ def test_prepare_solver_set_names_false_run( assert float(simple_model.variables["y"].solution) == pytest.approx(1.7) +def test_prepare_solver_unknown_name_raises(simple_model: Model) -> None: + with pytest.raises(ValueError, match="Unknown solver name"): + simple_model.prepare_solver("not_a_real_solver") + + @pytest.mark.skipif( "highs" not in set(solvers.available_solvers), reason="HiGHS is not installed" ) diff --git a/test/test_sos_constraints.py b/test/test_sos_constraints.py index 5d94162e..67447228 100644 --- a/test/test_sos_constraints.py +++ b/test/test_sos_constraints.py @@ -70,7 +70,8 @@ def test_to_gurobipy_emits_sos_constraints() -> None: m.add_sos_constraints(var, sos_type=1, sos_dim="seg") try: - model = m.to_gurobipy() + with pytest.warns(DeprecationWarning, match="to_gurobipy is deprecated"): + model = m.to_gurobipy() except gurobipy.GurobiError as exc: # pragma: no cover - depends on license setup pytest.skip(f"Gurobi environment unavailable: {exc}") @@ -158,8 +159,11 @@ def test_to_highspy_raises_not_implemented() -> None: build = m.add_variables(coords=[locations], name="build", binary=True) m.add_sos_constraints(build, sos_type=1, sos_dim="locations") - with pytest.raises( - NotImplementedError, - match="SOS constraints are not supported by the HiGHS direct API", + with ( + pytest.warns(DeprecationWarning, match="to_highspy is deprecated"), + pytest.raises( + NotImplementedError, + match="SOS constraints are not supported by the HiGHS direct API", + ), ): m.to_highspy() From 9975ac8f6fd0e9c59fde6646a47421cb884f547f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 09:23:56 +0000 Subject: [PATCH 15/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- linopy/constants.py | 4 +--- linopy/solvers.py | 18 +++++++++--------- test/test_io.py | 4 +--- test/test_solvers.py | 13 ++++--------- 4 files changed, 15 insertions(+), 24 deletions(-) diff --git a/linopy/constants.py b/linopy/constants.py index 9f449e01..13a1845f 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -311,9 +311,7 @@ def __repr__(self) -> str: ) else: solution_string = "Solution: None\n" - solver_name_string = ( - f"Solver: {self.solver_name}\n" if self.solver_name else "" - ) + solver_name_string = f"Solver: {self.solver_name}\n" if self.solver_name else "" report_string = "" if self.report is not None: if self.report.runtime is not None: diff --git a/linopy/solvers.py b/linopy/solvers.py index e1812d04..99784029 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -337,8 +337,10 @@ class Solver(ABC, Generic[EnvType]): @classmethod def runtime_features(cls) -> frozenset[SolverFeature]: - """Features whose availability depends on the installed solver version - or runtime environment. Override in subclasses; the default is empty.""" + """ + Features whose availability depends on the installed solver version + or runtime environment. Override in subclasses; the default is empty. + """ return frozenset() @classmethod @@ -1339,9 +1341,7 @@ def __init__( ) -> None: super().__init__(**solver_options) - def _resolve_env( - self, env: gurobipy.Env | dict[str, Any] | None - ) -> gurobipy.Env: + def _resolve_env(self, env: gurobipy.Env | dict[str, Any] | None) -> gurobipy.Env: self.close() self._env_stack = contextlib.ExitStack() if env is None: @@ -1623,7 +1623,9 @@ def get_solver_solution() -> Solution: try: constrs = m.getConstrs() if self._clabels is not None: - dual = pd.Series([c.Pi for c in constrs], self._clabels, dtype=float) + dual = pd.Series( + [c.Pi for c in constrs], self._clabels, dtype=float + ) else: dual = pd.Series({c.ConstrName: c.Pi for c in constrs}, dtype=float) except AttributeError: @@ -2439,9 +2441,7 @@ def get_solver_solution() -> Solution: status, solution, solver_model=knitro_model, - report=SolverReport( - runtime=reported_runtime, mip_gap=mip_rel_gap - ), + report=SolverReport(runtime=reported_runtime, mip_gap=mip_rel_gap), ) finally: with contextlib.suppress(Exception): diff --git a/test/test_io.py b/test/test_io.py index 55c1149b..5e57de4b 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -309,9 +309,7 @@ def test_to_mosek_deprecation_warning(model: Model) -> None: model.to_mosek() -@pytest.mark.skipif( - "cupdlpx" not in available_solvers, reason="cuPDLPx not installed" -) +@pytest.mark.skipif("cupdlpx" not in available_solvers, reason="cuPDLPx not installed") def test_to_cupdlpx_deprecation_warning(model: Model) -> None: with pytest.warns(DeprecationWarning, match="to_cupdlpx is deprecated"): model.to_cupdlpx() diff --git a/test/test_solvers.py b/test/test_solvers.py index 1a80e860..c2522a02 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -35,9 +35,7 @@ def simple_model() -> Model: @pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) -def test_solver_instance_attached_after_solve( - simple_model: Model, solver: str -) -> None: +def test_solver_instance_attached_after_solve(simple_model: Model, solver: str) -> None: simple_model.solve(solver) assert isinstance(simple_model.solver, solvers.Solver) assert simple_model.solver.status is not None @@ -78,9 +76,7 @@ def test_prepare_solver_then_run(simple_model: Model, solver: str) -> None: @pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) -def test_prepare_solver_set_names_false_run( - simple_model: Model, solver: str -) -> None: +def test_prepare_solver_set_names_false_run(simple_model: Model, solver: str) -> None: if not solver_supports(solver, SolverFeature.DIRECT_API): pytest.skip("Solver does not support direct API.") simple_model.prepare_solver(solver, set_names=False) @@ -134,9 +130,7 @@ def test_solver_state_compatibility_setters(simple_model: Model) -> None: def test_apply_result_explicit(simple_model: Model) -> None: x_labels = simple_model.variables["x"].labels.values y_labels = simple_model.variables["y"].labels.values - primal = pd.Series( - {int(x_labels): 1.5, int(y_labels): 2.0}, dtype=float - ) + primal = pd.Series({int(x_labels): 1.5, int(y_labels): 2.0}, dtype=float) solution = Solution(primal=primal, objective=5.5) result = Result( status=Status.from_termination_condition("optimal"), @@ -171,6 +165,7 @@ def test_solver_close_releases_state(simple_model: Model, solver: str) -> None: assert solver_instance.solver_model is None assert solver_instance.env is None + free_mps_problem = """NAME sample_mip ROWS N obj From 3d6ba0ebf6ff9f91bb77d15ef6bd2e84aa3bf11d Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 13 May 2026 14:25:54 +0200 Subject: [PATCH 16/27] Remove redundant solution sentinel assignment --- linopy/model.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/linopy/model.py b/linopy/model.py index 2767cd84..973d8e88 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -20,7 +20,7 @@ import pandas as pd import xarray as xr from deprecation import deprecated -from numpy import inf, nan, ndarray +from numpy import inf, ndarray from pandas.core.frame import DataFrame from pandas.core.series import Series from xarray import DataArray, Dataset @@ -1870,7 +1870,6 @@ def apply_result(self, result: Result | None = None) -> tuple[str, str]: sol = result.solution.primal.copy() sol = set_int_index(sol) - sol.loc[-1] = nan sol_arr = series_to_lookup_array(sol) @@ -1881,7 +1880,6 @@ def apply_result(self, result: Result | None = None) -> tuple[str, str]: if not result.solution.dual.empty: dual = result.solution.dual.copy() dual = set_int_index(dual) - dual.loc[-1] = nan dual_arr = series_to_lookup_array(dual) From 43a82aa04f85e7e9ad5cdc812e542050808959c6 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 13 May 2026 14:20:12 +0200 Subject: [PATCH 17/27] refactor: Solution.primal/dual as ndarray lookup arrays Replace pd.Series with dense NaN-padded np.ndarray keyed by integer model labels. Adds values_to_lookup_array helper; simplifies Model.apply_result; migrates every solver to build the lookup array directly (direct-API solvers via cached _vlabels/_clabels, file-based solvers via a shared _names_to_labels helper). Drops the now-unused series_to_lookup_array and all name-based solution fallbacks. --- doc/release_notes.rst | 1 + linopy/common.py | 35 +++--- linopy/constants.py | 9 +- linopy/model.py | 16 +-- linopy/solvers.py | 210 +++++++++++++++++++++++------------ test/test_solution_lookup.py | 41 ++++--- 6 files changed, 195 insertions(+), 117 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 60490057..49adeaab 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -10,6 +10,7 @@ Upcoming Version * ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``. * Direct-API solvers (HiGHS, Gurobi, Mosek, cuPDLPx) cache variable/constraint labels on the ``Solver`` instance, so solutions are reconstructed by label rather than by solver-side names — fixes label mapping when names are not set on the solver. * Per-solver translation helpers are unified under ``Solver._build_solver_model`` . The module-level ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` / ``to_cupdlpx`` (and their ``Model`` bindings) now emit ``DeprecationWarning`` with step-by-step migration to ``Solver.to_solver_model(model)`` or ``Model.prepare_solver(solver_name)``. +* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray`` lookup arrays keyed by integer model labels (length = ``max_label + 1``, NaN for unfilled positions). Code that introspected these as ``pd.Series`` must use ``np.ndarray`` semantics. Construction via ``pd.Series`` is no longer supported. * Increase speed of direct solver communication (~10x) in conversion functions like `to_highspy` through faster matrix creation (see below), leading to significant overall speed-up when setting `io_api="direct"`. * Add ``CSRConstraint``, a memory-efficient immutable constraint representation backed by scipy CSR sparse matrices. Provides up to 90% memory savings for constraints with many terms and 30–120x faster matrix generation for direct solver APIs. - Add ``freeze_constraints`` parameter to ``Model`` for globally storing constraints in CSR format on ``add_constraints``. diff --git a/linopy/common.py b/linopy/common.py index 162fcdfe..8afb4f88 100644 --- a/linopy/common.py +++ b/linopy/common.py @@ -1509,27 +1509,36 @@ def is_constant(x: SideLike) -> bool: ) -def series_to_lookup_array(s: pd.Series) -> np.ndarray: +def values_to_lookup_array( + values: np.ndarray, labels: np.ndarray, size: int | None = None +) -> np.ndarray: """ - Convert an integer-indexed Series to a dense numpy lookup array. + Build a dense NaN-padded lookup array from values and integer labels. - Non-negative indices are placed at their corresponding positions; - negative indices are ignored. Gaps are filled with NaN. + Non-negative labels are placed at their corresponding positions; negative + labels are skipped. Gaps are filled with NaN. Parameters ---------- - s : pd.Series - Series with an integer index. + values : np.ndarray + Values to place into the lookup array. + labels : np.ndarray + Integer labels giving the target position for each value. + size : int, optional + Length of the returned array. Defaults to ``max(labels) + 1`` if any + non-negative label is present, otherwise 0. Returns ------- np.ndarray - Dense array of length ``max(index) + 1``. - """ - max_idx = max(int(s.index.max()), 0) - arr = np.full(max_idx + 1, nan) - mask = s.index >= 0 - arr[s.index[mask]] = s.values[mask] + Dense float lookup array. + """ + labels = np.asarray(labels, dtype=int) + mask = labels >= 0 + if size is None: + size = int(labels[mask].max()) + 1 if mask.any() else 0 + arr = np.full(size, nan, dtype=float) + arr[labels[mask]] = values[mask] return arr @@ -1542,7 +1551,7 @@ def lookup_vals(arr: np.ndarray, idx: np.ndarray) -> np.ndarray: Parameters ---------- arr : np.ndarray - Dense lookup array (e.g. from :func:`series_to_lookup_array`). + Dense lookup array (e.g. from :func:`values_to_lookup_array`). idx : np.ndarray Integer label indices. diff --git a/linopy/constants.py b/linopy/constants.py index 13a1845f..8cf93a30 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -9,7 +9,6 @@ from typing import Any, Literal, TypeAlias, Union, get_args import numpy as np -import pandas as pd logger = logging.getLogger(__name__) @@ -261,18 +260,14 @@ def is_ok(self) -> bool: return self.status == SolverStatus.ok -def _pd_series_float() -> pd.Series: - return pd.Series(dtype=float) - - @dataclass class Solution: """ Solution returned by the solver. """ - primal: pd.Series = field(default_factory=_pd_series_float) - dual: pd.Series = field(default_factory=_pd_series_float) + primal: np.ndarray = field(default_factory=lambda: np.array([], dtype=float)) + dual: np.ndarray = field(default_factory=lambda: np.array([], dtype=float)) objective: float = field(default=np.nan) diff --git a/linopy/model.py b/linopy/model.py index 973d8e88..e3207ea6 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -35,8 +35,6 @@ lookup_vals, maybe_replace_signs, replace_by_map, - series_to_lookup_array, - set_int_index, to_path, ) from linopy.constants import ( @@ -1868,21 +1866,13 @@ def apply_result(self, result: Result | None = None) -> tuple[str, str]: if result.solution is None or len(result.solution.primal) == 0: return status_value, termination_condition - sol = result.solution.primal.copy() - sol = set_int_index(sol) - - sol_arr = series_to_lookup_array(sol) - + sol_arr = result.solution.primal for _, var in self.variables.items(): vals = lookup_vals(sol_arr, np.ravel(var.labels)) var.solution = xr.DataArray(vals.reshape(var.labels.shape), var.coords) - if not result.solution.dual.empty: - dual = result.solution.dual.copy() - dual = set_int_index(dual) - - dual_arr = series_to_lookup_array(dual) - + if len(result.solution.dual): + dual_arr = result.solution.dual for _, con in self.constraints.items(): vals = lookup_vals(dual_arr, np.ravel(con.labels)) con.dual = xr.DataArray( diff --git a/linopy/solvers.py b/linopy/solvers.py index 99784029..f7acb827 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -32,6 +32,7 @@ from scipy.sparse import tril, triu import linopy.io +from linopy.common import count_initial_letters, values_to_lookup_array from linopy.constants import ( SOS_DIM_ATTR, SOS_TYPE_ATTR, @@ -44,6 +45,23 @@ ) +def _parse_int_label(name: str) -> int: + """Strip leading non-digits and parse the integer label.""" + s = str(name) + cutoff = count_initial_letters(s) + try: + return int(s[cutoff:]) + except ValueError: + return int(re.sub(r".*#", "", s)) + + +def _names_to_labels(names: Any) -> np.ndarray: + """Vectorised conversion of solver-provided names to integer labels.""" + return np.fromiter( + (_parse_int_label(n) for n in names), dtype=np.int64, count=len(names) + ) + + class SolverFeature(Enum): """Enumeration of all solver capabilities tracked by linopy.""" @@ -746,9 +764,16 @@ def get_solver_solution() -> Solution: ) variables_b = df.index.isin(variables) - sol = df[variables_b][2] - dual = df[~variables_b][3] - + sol_df = df[variables_b] + dual_df = df[~variables_b] + sol = values_to_lookup_array( + sol_df[2].to_numpy(dtype=float), + _names_to_labels(sol_df.index.to_list()), + ) + dual = values_to_lookup_array( + dual_df[3].to_numpy(dtype=float), + _names_to_labels(dual_df.index.to_list()), + ) return Solution(sol, dual, objective) solution = self.safe_get_solution(status=status, func=get_solver_solution) @@ -937,16 +962,21 @@ 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) + dual_values = ( + pd.to_numeric(dual_["Marginal"], "coerce").fillna(0).to_numpy(dtype=float) + ) + dual = values_to_lookup_array( + dual_values, _names_to_labels(dual_.index.to_list()) + ) else: logger.warning("Dual values of MILP couldn't be parsed") - dual = pd.Series(dtype=float) + dual = np.array([], dtype=float) sol_io = io.StringIO("".join(read_until_break(f))[:-2]) - sol = ( - pd.read_fwf(sol_io)[1:] - .set_index("Column name")["Activity"] - .astype(float) + sol_df = pd.read_fwf(sol_io)[1:].set_index("Column name") + sol = values_to_lookup_array( + sol_df["Activity"].astype(float).to_numpy(), + _names_to_labels(sol_df.index.to_list()), ) f.close() return Solution(sol, dual, objective) @@ -1279,15 +1309,22 @@ def get_solver_solution() -> Solution: solution = h.getSolution() if model is not None: - sol = pd.Series(solution.col_value, model.matrices.vlabels, dtype=float) - dual = pd.Series(solution.row_dual, model.matrices.clabels, dtype=float) + vlabels = model.matrices.vlabels + clabels = model.matrices.clabels elif self._vlabels is not None and self._clabels is not None: - sol = pd.Series(solution.col_value, self._vlabels, dtype=float) - dual = pd.Series(solution.row_dual, self._clabels, dtype=float) + vlabels = self._vlabels + clabels = self._clabels else: - sol = pd.Series(solution.col_value, h.getLp().col_names_, dtype=float) - dual = pd.Series(solution.row_dual, h.getLp().row_names_, dtype=float) + lp = h.getLp() + vlabels = _names_to_labels(lp.col_names_) + clabels = _names_to_labels(lp.row_names_) + sol = values_to_lookup_array( + np.asarray(solution.col_value, dtype=float), vlabels + ) + dual = values_to_lookup_array( + np.asarray(solution.row_dual, dtype=float), clabels + ) return Solution(sol, dual, objective) solution = self.safe_get_solution(status=status, func=get_solver_solution) @@ -1615,22 +1652,24 @@ def get_solver_solution() -> Solution: objective = m.ObjVal vars = m.getVars() + sol_values = np.array([v.X for v in vars], dtype=float) if self._vlabels is not None: - sol = pd.Series([v.X for v in vars], self._vlabels, dtype=float) + vlabels = self._vlabels else: - sol = pd.Series({v.VarName: v.X for v in vars}, dtype=float) + vlabels = _names_to_labels([v.VarName for v in vars]) + sol = values_to_lookup_array(sol_values, vlabels) try: constrs = m.getConstrs() + dual_values = np.array([c.Pi for c in constrs], dtype=float) if self._clabels is not None: - dual = pd.Series( - [c.Pi for c in constrs], self._clabels, dtype=float - ) + clabels = self._clabels else: - dual = pd.Series({c.ConstrName: c.Pi for c in constrs}, dtype=float) + clabels = _names_to_labels([c.ConstrName for c in constrs]) + dual = values_to_lookup_array(dual_values, clabels) except AttributeError: logger.warning("Dual values of MILP couldn't be parsed") - dual = pd.Series(dtype=float) + dual = np.array([], dtype=float) return Solution(sol, dual, objective) @@ -1801,21 +1840,21 @@ def get_solver_solution() -> Solution: objective = m.solution.get_objective_value() - solution = pd.Series( - m.solution.get_values(), m.variables.get_names(), dtype=float + solution = values_to_lookup_array( + np.asarray(m.solution.get_values(), dtype=float), + _names_to_labels(m.variables.get_names()), ) try: - dual = pd.Series( - m.solution.get_dual_values(), - m.linear_constraints.get_names(), - dtype=float, + dual = values_to_lookup_array( + np.asarray(m.solution.get_dual_values(), dtype=float), + _names_to_labels(m.linear_constraints.get_names()), ) except Exception: logger.warning( "Dual values not available (e.g. barrier solution without crossover)" ) - dual = pd.Series(dtype=float) + dual = np.array([], dtype=float) return Solution(solution, dual, objective) solution = self.safe_get_solution(status=status, func=get_solver_solution) @@ -1965,22 +2004,28 @@ def get_solver_solution() -> Solution: vars_to_ignore = {"quadobjvar", "qmatrixvar", "quadobj", "qmatrix"} s = m.getSols()[0] - sol = pd.Series( - {v.name: s[v] for v in m.getVars() if v.name not in vars_to_ignore} + var_items = [ + (v.name, s[v]) for v in m.getVars() if v.name not in vars_to_ignore + ] + sol = values_to_lookup_array( + np.array([val for _, val in var_items], dtype=float), + _names_to_labels([name for name, _ in var_items]), ) cons = m.getConss(False) if len(cons) != 0: - dual = pd.Series( - { - c.name: m.getDualSolVal(c) - for c in cons - if c.name not in vars_to_ignore - } + con_items = [ + (c.name, m.getDualSolVal(c)) + for c in cons + if c.name not in vars_to_ignore + ] + dual = values_to_lookup_array( + np.array([val for _, val in con_items], dtype=float), + _names_to_labels([name for name, _ in con_items]), ) else: logger.warning("Dual values not available (is this an MILP?)") - dual = pd.Series(dtype=float) + dual = np.array([], dtype=float) return Solution(sol, dual, objective) @@ -2151,11 +2196,13 @@ def get_solver_solution() -> Solution: var = m.getNameList(xpress_Namespaces.COLUMN, 0, m.attributes.cols - 1) except AttributeError: # Fallback to old API var = m.getnamelist(xpress_Namespaces.COLUMN, 0, m.attributes.cols - 1) - sol = pd.Series(m.getSolution(), index=var, dtype=float) + sol = values_to_lookup_array( + np.asarray(m.getSolution(), dtype=float), _names_to_labels(var) + ) try: if m.attributes.rows == 0: - dual = pd.Series(dtype=float) + dual = np.array([], dtype=float) else: try: # Try new API first _dual = m.getDuals() @@ -2170,10 +2217,12 @@ def get_solver_solution() -> Solution: constraints = m.getnamelist( xpress_Namespaces.ROW, 0, m.attributes.rows - 1 ) - dual = pd.Series(_dual, index=constraints, dtype=float) + dual = values_to_lookup_array( + np.asarray(_dual, dtype=float), _names_to_labels(constraints) + ) except (xpress.SolverError, xpress.ModelError, SystemError): logger.warning("Dual values of MILP couldn't be parsed") - dual = pd.Series(dtype=float) + dual = np.array([], dtype=float) return Solution(sol, dual, objective) @@ -2257,10 +2306,10 @@ def _extract_values( get_count_fn: Callable[..., Any], get_values_fn: Callable[..., Any], get_names_fn: Callable[..., Any], - ) -> pd.Series: + ) -> np.ndarray: n = int(get_count_fn(kc)) if n == 0: - return pd.Series(dtype=float) + return np.array([], dtype=float) try: # Compatible with KNITRO >= 15 @@ -2270,7 +2319,9 @@ def _extract_values( values = get_values_fn(kc, list(range(n))) names = list(get_names_fn(kc)) - return pd.Series(values, index=names, dtype=float) + return values_to_lookup_array( + np.asarray(values, dtype=float), _names_to_labels(names) + ) def solve_problem_from_file( self, @@ -2409,7 +2460,7 @@ def get_solver_solution() -> Solution: ) except Exception: logger.warning("Dual values couldn't be parsed") - dual = pd.Series(dtype=float) + dual = np.array([], dtype=float) return Solution(sol, dual, objective) @@ -2884,25 +2935,27 @@ def _solve( def get_solver_solution() -> Solution: objective = m.getprimalobj(soltype) - sol_values = m.getxx(soltype) + sol_values = np.asarray(m.getxx(soltype), dtype=float) if self._vlabels is not None: - sol = pd.Series(sol_values, self._vlabels, dtype=float) + vlabels = self._vlabels else: - sol = {m.getvarname(i): sol_values[i] for i in range(m.getnumvar())} - sol = pd.Series(sol, dtype=float) + vlabels = _names_to_labels( + [m.getvarname(i) for i in range(m.getnumvar())] + ) + sol = values_to_lookup_array(sol_values, vlabels) try: - dual_values = m.gety(soltype) + dual_values = np.asarray(m.gety(soltype), dtype=float) if self._clabels is not None: - dual = pd.Series(dual_values, self._clabels, dtype=float) + clabels = self._clabels else: - dual = { - m.getconname(i): dual_values[i] for i in range(m.getnumcon()) - } - dual = pd.Series(dual, dtype=float) + clabels = _names_to_labels( + [m.getconname(i) for i in range(m.getnumcon())] + ) + dual = values_to_lookup_array(dual_values, clabels) except (mosek.Error, AttributeError): logger.warning("Dual values of MILP couldn't be parsed") - dual = pd.Series(dtype=float) + dual = np.array([], dtype=float) return Solution(sol, dual, objective) @@ -3048,13 +3101,21 @@ def get_solver_solution() -> Solution: # TODO: check if this suffices objective = m.BestObj if m.ismip else m.LpObjVal - sol = pd.Series({v.name: v.x for v in m.getVars()}, dtype=float) + vars_ = m.getVars() + sol = values_to_lookup_array( + np.array([v.x for v in vars_], dtype=float), + _names_to_labels([v.name for v in vars_]), + ) try: - dual = pd.Series({v.name: v.pi for v in m.getConstrs()}, dtype=float) + constrs = m.getConstrs() + dual = values_to_lookup_array( + np.array([c.pi for c in constrs], dtype=float), + _names_to_labels([c.name for c in constrs]), + ) except (coptpy.CoptError, AttributeError): logger.warning("Dual values of MILP couldn't be parsed") - dual = pd.Series(dtype=float) + dual = np.array([], dtype=float) return Solution(sol, dual, objective) @@ -3204,13 +3265,21 @@ def solve_problem_from_file( def get_solver_solution() -> Solution: objective = m.objval - sol = pd.Series({v.varname: v.X for v in m.getVars()}, dtype=float) + vars_ = m.getVars() + sol = values_to_lookup_array( + np.array([v.X for v in vars_], dtype=float), + _names_to_labels([v.varname for v in vars_]), + ) try: - dual = pd.Series({c.constrname: c.DualSoln for c in m.getConstrs()}) + constrs = m.getConstrs() + dual = values_to_lookup_array( + np.array([c.DualSoln for c in constrs], dtype=float), + _names_to_labels([c.constrname for c in constrs]), + ) except (mindoptpy.MindoptError, AttributeError): logger.warning("Dual values of MILP couldn't be parsed") - dual = pd.Series(dtype=float) + dual = np.array([], dtype=float) return Solution(sol, dual, objective) @@ -3498,14 +3567,19 @@ def _solve( def get_solver_solution() -> Solution: objective = cu_model.ObjVal - vlabels = l_model.matrices.vlabels if l_model is not None else self._vlabels - clabels = l_model.matrices.clabels if l_model is not None else self._clabels + if l_model is not None: + vlabels = l_model.matrices.vlabels + clabels = l_model.matrices.clabels + else: + assert self._vlabels is not None and self._clabels is not None + vlabels = self._vlabels + clabels = self._clabels - sol = pd.Series(cu_model.X, vlabels, dtype=float) - dual = pd.Series(cu_model.Pi, clabels, dtype=float) + sol = values_to_lookup_array(np.asarray(cu_model.X, dtype=float), vlabels) + dual = values_to_lookup_array(np.asarray(cu_model.Pi, dtype=float), clabels) if cu_model.ModelSense == cupdlpx.PDLP.MAXIMIZE: - dual *= -1 # flip sign of duals for max problems + dual = -dual return Solution(sol, dual, objective) diff --git a/test/test_solution_lookup.py b/test/test_solution_lookup.py index 7dd9643f..def4494b 100644 --- a/test/test_solution_lookup.py +++ b/test/test_solution_lookup.py @@ -1,36 +1,45 @@ import numpy as np -import pandas as pd from numpy import nan -from linopy.common import lookup_vals, series_to_lookup_array +from linopy.common import lookup_vals, values_to_lookup_array -class TestSeriesToLookupArray: +class TestValuesToLookupArray: def test_basic(self) -> None: - s = pd.Series([10.0, 20.0, 30.0], index=pd.Index([0, 1, 2])) - arr = series_to_lookup_array(s) + arr = values_to_lookup_array( + np.array([10.0, 20.0, 30.0]), np.array([0, 1, 2]) + ) np.testing.assert_array_equal(arr, [10.0, 20.0, 30.0]) - def test_with_negative_index(self) -> None: - s = pd.Series([nan, 10.0, 20.0], index=pd.Index([-1, 0, 2])) - arr = series_to_lookup_array(s) + def test_negative_labels_skipped(self) -> None: + arr = values_to_lookup_array( + np.array([nan, 10.0, 20.0]), np.array([-1, 0, 2]) + ) assert arr[0] == 10.0 assert np.isnan(arr[1]) assert arr[2] == 20.0 - def test_sparse_index(self) -> None: - s = pd.Series([5.0, 7.0], index=pd.Index([0, 100])) - arr = series_to_lookup_array(s) + def test_sparse_labels(self) -> None: + arr = values_to_lookup_array(np.array([5.0, 7.0]), np.array([0, 100])) assert len(arr) == 101 assert arr[0] == 5.0 assert arr[100] == 7.0 assert np.isnan(arr[50]) - def test_only_negative_index(self) -> None: - s = pd.Series([nan], index=pd.Index([-1])) - arr = series_to_lookup_array(s) - assert len(arr) == 1 - assert np.isnan(arr[0]) + def test_only_negative_labels(self) -> None: + arr = values_to_lookup_array(np.array([nan]), np.array([-1])) + assert len(arr) == 0 + + def test_explicit_size(self) -> None: + arr = values_to_lookup_array( + np.array([5.0, 7.0]), np.array([0, 2]), size=5 + ) + assert len(arr) == 5 + assert arr[0] == 5.0 + assert arr[2] == 7.0 + assert np.isnan(arr[1]) + assert np.isnan(arr[3]) + assert np.isnan(arr[4]) class TestLookupVals: From cd8e7f8b9a29b5c8f22f393bb0f09f38d2f6d8e9 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 13 May 2026 15:00:04 +0200 Subject: [PATCH 18/27] refactor: drop label state from Solver, primal/dual in build order --- doc/release_notes.rst | 4 +- linopy/constants.py | 3 + linopy/model.py | 7 +- linopy/solvers.py | 222 +++++++++++------------------------------- 4 files changed, 69 insertions(+), 167 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 49adeaab..4ca171f7 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -8,9 +8,9 @@ Upcoming Version * Two-step solve workflow *for advanced users*: ``Model.prepare_solver(solver_name, ...)`` builds the native solver model without solving, and ``Model.run_solver()`` runs it. ``Model.apply_result(result=None)`` exposes the solution-mapping step and defaults to the state on ``model.solver``. Only use these methods if you want to control optimization on the solver instance directly. * Solver capabilities are declared as ``features: frozenset[SolverFeature]`` ClassVars on each ``Solver`` subclass; use ``Solver.supports(feature)``. ``SolverFeature`` is now exported from ``linopy`` (and from ``linopy.solvers``); ``linopy.solver_capabilities`` remains as a back-compat shim with a lazy ``SOLVER_REGISTRY`` mapping. * ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``. -* Direct-API solvers (HiGHS, Gurobi, Mosek, cuPDLPx) cache variable/constraint labels on the ``Solver`` instance, so solutions are reconstructed by label rather than by solver-side names — fixes label mapping when names are not set on the solver. +* Direct-API solvers (HiGHS, Gurobi, Mosek, cuPDLPx) now return primals/duals in build order, so solutions are reconstructed positionally rather than by solver-side names — fixes label mapping when names are not set on the solver. * Per-solver translation helpers are unified under ``Solver._build_solver_model`` . The module-level ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` / ``to_cupdlpx`` (and their ``Model`` bindings) now emit ``DeprecationWarning`` with step-by-step migration to ``Solver.to_solver_model(model)`` or ``Model.prepare_solver(solver_name)``. -* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray`` lookup arrays keyed by integer model labels (length = ``max_label + 1``, NaN for unfilled positions). Code that introspected these as ``pd.Series`` must use ``np.ndarray`` semantics. Construction via ``pd.Series`` is no longer supported. +* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray``\ s of values in ``model.matrices.vlabels``/``clabels`` build order (length = ``n_vars`` / ``n_cons``). Previously they were ``pd.Series`` keyed by variable/constraint name. Internal -- code that introspected these must now pair with ``model.matrices.vlabels``/``clabels`` to interpret. * Increase speed of direct solver communication (~10x) in conversion functions like `to_highspy` through faster matrix creation (see below), leading to significant overall speed-up when setting `io_api="direct"`. * Add ``CSRConstraint``, a memory-efficient immutable constraint representation backed by scipy CSR sparse matrices. Provides up to 90% memory savings for constraints with many terms and 30–120x faster matrix generation for direct solver APIs. - Add ``freeze_constraints`` parameter to ``Model`` for globally storing constraints in CSR format on ``add_constraints``. diff --git a/linopy/constants.py b/linopy/constants.py index 8cf93a30..7180f9ac 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -264,6 +264,9 @@ def is_ok(self) -> bool: class Solution: """ Solution returned by the solver. + + ``primal`` and ``dual`` are values in ``vlabels``/``clabels`` build order + -- ``primal[i]`` is the value for variable label ``vlabels[i]``. """ primal: np.ndarray = field(default_factory=lambda: np.array([], dtype=float)) diff --git a/linopy/model.py b/linopy/model.py index e3207ea6..bf14137e 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -36,6 +36,7 @@ maybe_replace_signs, replace_by_map, to_path, + values_to_lookup_array, ) from linopy.constants import ( GREATER_EQUAL, @@ -1866,13 +1867,15 @@ def apply_result(self, result: Result | None = None) -> tuple[str, str]: if result.solution is None or len(result.solution.primal) == 0: return status_value, termination_condition - sol_arr = result.solution.primal + primal = result.solution.primal + sol_arr = values_to_lookup_array(primal, self.matrices.vlabels) for _, var in self.variables.items(): vals = lookup_vals(sol_arr, np.ravel(var.labels)) var.solution = xr.DataArray(vals.reshape(var.labels.shape), var.coords) if len(result.solution.dual): - dual_arr = result.solution.dual + dual = result.solution.dual + dual_arr = values_to_lookup_array(dual, self.matrices.clabels) for _, con in self.constraints.items(): vals = lookup_vals(dual_arr, np.ravel(con.labels)) con.dual = xr.DataArray( diff --git a/linopy/solvers.py b/linopy/solvers.py index f7acb827..372cabfc 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -32,7 +32,7 @@ from scipy.sparse import tril, triu import linopy.io -from linopy.common import count_initial_letters, values_to_lookup_array +from linopy.common import count_initial_letters from linopy.constants import ( SOS_DIM_ATTR, SOS_TYPE_ATTR, @@ -384,8 +384,6 @@ def __init__( self.io_api: str | None = None self.sense: str | None = None self.env: Any = None - self._vlabels: np.ndarray | None = None - self._clabels: np.ndarray | None = None self._env_stack: contextlib.ExitStack | None = None if self.solver_name.value not in available_solvers: @@ -398,10 +396,6 @@ def to_solver_model(self, model: Model, **kwargs: Any) -> Any: def update_solver_model(self, model: Model, **kwargs: Any) -> None: raise NotImplementedError - def _store_labels(self, model: Model) -> None: - self._vlabels = model.matrices.vlabels.copy() - self._clabels = model.matrices.clabels.copy() - def run(self) -> Result: if self.solver_model is None: raise RuntimeError("call to_solver_model first") @@ -418,8 +412,6 @@ def close(self) -> None: self.env = None self.solver_model = None self._env_stack = None - self._vlabels = None - self._clabels = None def __del__(self) -> None: with contextlib.suppress(Exception): @@ -766,14 +758,8 @@ def get_solver_solution() -> Solution: sol_df = df[variables_b] dual_df = df[~variables_b] - sol = values_to_lookup_array( - sol_df[2].to_numpy(dtype=float), - _names_to_labels(sol_df.index.to_list()), - ) - dual = values_to_lookup_array( - dual_df[3].to_numpy(dtype=float), - _names_to_labels(dual_df.index.to_list()), - ) + sol = sol_df[2].to_numpy(dtype=float) + dual = dual_df[3].to_numpy(dtype=float) return Solution(sol, dual, objective) solution = self.safe_get_solution(status=status, func=get_solver_solution) @@ -962,11 +948,10 @@ 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_values = ( - pd.to_numeric(dual_["Marginal"], "coerce").fillna(0).to_numpy(dtype=float) - ) - dual = values_to_lookup_array( - dual_values, _names_to_labels(dual_.index.to_list()) + dual = ( + pd.to_numeric(dual_["Marginal"], "coerce") + .fillna(0) + .to_numpy(dtype=float) ) else: logger.warning("Dual values of MILP couldn't be parsed") @@ -974,10 +959,7 @@ def get_solver_solution() -> Solution: sol_io = io.StringIO("".join(read_until_break(f))[:-2]) sol_df = pd.read_fwf(sol_io)[1:].set_index("Column name") - sol = values_to_lookup_array( - sol_df["Activity"].astype(float).to_numpy(), - _names_to_labels(sol_df.index.to_list()), - ) + sol = sol_df["Activity"].astype(float).to_numpy() f.close() return Solution(sol, dual, objective) @@ -1052,7 +1034,6 @@ def to_solver_model( explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, ) - self._store_labels(model) self._set_solver_params(h, log_fn) self.solver_model = h self.io_api = "direct" @@ -1150,7 +1131,6 @@ def solve_problem_from_model( solution_fn, warmstart_fn, basis_fn, - model=model, io_api="direct", sense=model.sense, ) @@ -1198,8 +1178,6 @@ def solve_problem_from_file( self._set_solver_params(h, log_fn) h.readModel(problem_fn_) - self._vlabels = None - self._clabels = None self.solver_model = h self.io_api = read_io_api_from_problem_file(problem_fn) @@ -1210,6 +1188,7 @@ def solve_problem_from_file( basis_fn, io_api=self.io_api, sense=read_sense_from_problem_file(problem_fn), + from_file=True, ) def _set_solver_params( @@ -1230,9 +1209,9 @@ def _solve( solution_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, - model: Model | None = None, io_api: str | None = None, sense: str | None = None, + from_file: bool = False, ) -> Result: """ Solve a linear problem from a HiGHS object. @@ -1250,12 +1229,13 @@ def _solve( Path to the warmstart file. basis_fn : Path, optional Path to the basis file. - model : linopy.model, optional - Linopy model for the problem. io_api: str io_api of the problem. For direct API from linopy model this is "direct". sense: str "min" or "max" + from_file: bool + ``True`` when ``h`` was populated via ``readModel`` — HiGHS may have + reordered columns/rows, so values are re-permuted using parsed names. Returns ------- @@ -1307,24 +1287,18 @@ def _solve( def get_solver_solution() -> Solution: objective = h.getObjectiveValue() solution = h.getSolution() - - if model is not None: - vlabels = model.matrices.vlabels - clabels = model.matrices.clabels - elif self._vlabels is not None and self._clabels is not None: - vlabels = self._vlabels - clabels = self._clabels - else: + sol = np.asarray(solution.col_value, dtype=float) + dual = np.asarray(solution.row_dual, dtype=float) + if from_file: lp = h.getLp() - vlabels = _names_to_labels(lp.col_names_) - clabels = _names_to_labels(lp.row_names_) - - sol = values_to_lookup_array( - np.asarray(solution.col_value, dtype=float), vlabels - ) - dual = values_to_lookup_array( - np.asarray(solution.row_dual, dtype=float), clabels - ) + if len(lp.col_names_): + vlabels = _names_to_labels(lp.col_names_) + keep = vlabels >= 0 + sol = sol[keep][np.argsort(vlabels[keep])] + if len(lp.row_names_): + clabels = _names_to_labels(lp.row_names_) + keep = clabels >= 0 + dual = dual[keep][np.argsort(clabels[keep])] return Solution(sol, dual, objective) solution = self.safe_get_solution(status=status, func=get_solver_solution) @@ -1405,7 +1379,6 @@ def to_solver_model( explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, ) - self._store_labels(model) self.solver_model = m self.io_api = "direct" self.sense = model.sense @@ -1563,6 +1536,7 @@ def solve_problem_from_file( basis_fn=basis_fn, io_api=io_api, sense=sense, + from_file=True, ) def _solve( @@ -1574,6 +1548,7 @@ def _solve( basis_fn: Path | None, io_api: str | None, sense: str | None, + from_file: bool = False, ) -> Result: """ Solve a linear problem from a Gurobi object. @@ -1651,22 +1626,20 @@ def _solve( def get_solver_solution() -> Solution: objective = m.ObjVal - vars = m.getVars() - sol_values = np.array([v.X for v in vars], dtype=float) - if self._vlabels is not None: - vlabels = self._vlabels - else: - vlabels = _names_to_labels([v.VarName for v in vars]) - sol = values_to_lookup_array(sol_values, vlabels) + vars_ = m.getVars() + sol = np.array([v.X for v in vars_], dtype=float) + if from_file and len(vars_): + vlabels = _names_to_labels([v.VarName for v in vars_]) + keep = vlabels >= 0 + sol = sol[keep][np.argsort(vlabels[keep])] try: constrs = m.getConstrs() - dual_values = np.array([c.Pi for c in constrs], dtype=float) - if self._clabels is not None: - clabels = self._clabels - else: + dual = np.array([c.Pi for c in constrs], dtype=float) + if from_file and len(constrs): clabels = _names_to_labels([c.ConstrName for c in constrs]) - dual = values_to_lookup_array(dual_values, clabels) + keep = clabels >= 0 + dual = dual[keep][np.argsort(clabels[keep])] except AttributeError: logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) @@ -1840,16 +1813,10 @@ def get_solver_solution() -> Solution: objective = m.solution.get_objective_value() - solution = values_to_lookup_array( - np.asarray(m.solution.get_values(), dtype=float), - _names_to_labels(m.variables.get_names()), - ) + solution = np.asarray(m.solution.get_values(), dtype=float) try: - dual = values_to_lookup_array( - np.asarray(m.solution.get_dual_values(), dtype=float), - _names_to_labels(m.linear_constraints.get_names()), - ) + dual = np.asarray(m.solution.get_dual_values(), dtype=float) except Exception: logger.warning( "Dual values not available (e.g. barrier solution without crossover)" @@ -2004,24 +1971,20 @@ def get_solver_solution() -> Solution: vars_to_ignore = {"quadobjvar", "qmatrixvar", "quadobj", "qmatrix"} s = m.getSols()[0] - var_items = [ - (v.name, s[v]) for v in m.getVars() if v.name not in vars_to_ignore - ] - sol = values_to_lookup_array( - np.array([val for _, val in var_items], dtype=float), - _names_to_labels([name for name, _ in var_items]), + sol = np.array( + [s[v] for v in m.getVars() if v.name not in vars_to_ignore], + dtype=float, ) cons = m.getConss(False) if len(cons) != 0: - con_items = [ - (c.name, m.getDualSolVal(c)) - for c in cons - if c.name not in vars_to_ignore - ] - dual = values_to_lookup_array( - np.array([val for _, val in con_items], dtype=float), - _names_to_labels([name for name, _ in con_items]), + dual = np.array( + [ + m.getDualSolVal(c) + for c in cons + if c.name not in vars_to_ignore + ], + dtype=float, ) else: logger.warning("Dual values not available (is this an MILP?)") @@ -2192,13 +2155,7 @@ def solve_problem_from_file( def get_solver_solution() -> Solution: objective = m.attributes.objval - try: # Try new API first - var = m.getNameList(xpress_Namespaces.COLUMN, 0, m.attributes.cols - 1) - except AttributeError: # Fallback to old API - var = m.getnamelist(xpress_Namespaces.COLUMN, 0, m.attributes.cols - 1) - sol = values_to_lookup_array( - np.asarray(m.getSolution(), dtype=float), _names_to_labels(var) - ) + sol = np.asarray(m.getSolution(), dtype=float) try: if m.attributes.rows == 0: @@ -2208,18 +2165,7 @@ def get_solver_solution() -> Solution: _dual = m.getDuals() except AttributeError: # Fallback to old API _dual = m.getDual() - - try: # Try new API first - constraints = m.getNameList( - xpress_Namespaces.ROW, 0, m.attributes.rows - 1 - ) - except AttributeError: # Fallback to old API - constraints = m.getnamelist( - xpress_Namespaces.ROW, 0, m.attributes.rows - 1 - ) - dual = values_to_lookup_array( - np.asarray(_dual, dtype=float), _names_to_labels(constraints) - ) + dual = np.asarray(_dual, dtype=float) except (xpress.SolverError, xpress.ModelError, SystemError): logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) @@ -2305,7 +2251,6 @@ def _extract_values( kc: Any, get_count_fn: Callable[..., Any], get_values_fn: Callable[..., Any], - get_names_fn: Callable[..., Any], ) -> np.ndarray: n = int(get_count_fn(kc)) if n == 0: @@ -2318,10 +2263,7 @@ def _extract_values( # Fallback for older wrappers requiring explicit indices values = get_values_fn(kc, list(range(n))) - names = list(get_names_fn(kc)) - return values_to_lookup_array( - np.asarray(values, dtype=float), _names_to_labels(names) - ) + return np.asarray(values, dtype=float) def solve_problem_from_file( self, @@ -2448,7 +2390,6 @@ def get_solver_solution() -> Solution: kc, knitro.KN_get_number_vars, knitro.KN_get_var_primal_values, - knitro.KN_get_var_names, ) try: @@ -2456,7 +2397,6 @@ def get_solver_solution() -> Solution: kc, knitro.KN_get_number_cons, knitro.KN_get_con_dual_values, - knitro.KN_get_con_names, ) except Exception: logger.warning("Dual values couldn't be parsed") @@ -2601,7 +2541,6 @@ def to_solver_model( explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, ) - self._store_labels(model) self.solver_model = m self.io_api = "direct" self.sense = model.sense @@ -2935,24 +2874,10 @@ def _solve( def get_solver_solution() -> Solution: objective = m.getprimalobj(soltype) - sol_values = np.asarray(m.getxx(soltype), dtype=float) - if self._vlabels is not None: - vlabels = self._vlabels - else: - vlabels = _names_to_labels( - [m.getvarname(i) for i in range(m.getnumvar())] - ) - sol = values_to_lookup_array(sol_values, vlabels) + sol = np.asarray(m.getxx(soltype), dtype=float) try: - dual_values = np.asarray(m.gety(soltype), dtype=float) - if self._clabels is not None: - clabels = self._clabels - else: - clabels = _names_to_labels( - [m.getconname(i) for i in range(m.getnumcon())] - ) - dual = values_to_lookup_array(dual_values, clabels) + dual = np.asarray(m.gety(soltype), dtype=float) except (mosek.Error, AttributeError): logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) @@ -3101,18 +3026,10 @@ def get_solver_solution() -> Solution: # TODO: check if this suffices objective = m.BestObj if m.ismip else m.LpObjVal - vars_ = m.getVars() - sol = values_to_lookup_array( - np.array([v.x for v in vars_], dtype=float), - _names_to_labels([v.name for v in vars_]), - ) + sol = np.array([v.x for v in m.getVars()], dtype=float) try: - constrs = m.getConstrs() - dual = values_to_lookup_array( - np.array([c.pi for c in constrs], dtype=float), - _names_to_labels([c.name for c in constrs]), - ) + dual = np.array([c.pi for c in m.getConstrs()], dtype=float) except (coptpy.CoptError, AttributeError): logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) @@ -3265,18 +3182,10 @@ def solve_problem_from_file( def get_solver_solution() -> Solution: objective = m.objval - vars_ = m.getVars() - sol = values_to_lookup_array( - np.array([v.X for v in vars_], dtype=float), - _names_to_labels([v.varname for v in vars_]), - ) + sol = np.array([v.X for v in m.getVars()], dtype=float) try: - constrs = m.getConstrs() - dual = values_to_lookup_array( - np.array([c.DualSoln for c in constrs], dtype=float), - _names_to_labels([c.constrname for c in constrs]), - ) + dual = np.array([c.DualSoln for c in m.getConstrs()], dtype=float) except (mindoptpy.MindoptError, AttributeError): logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) @@ -3413,7 +3322,6 @@ def solve_problem_from_model( return self._solve( self.solver_model, - l_model=model, solution_fn=solution_fn, log_fn=log_fn, warmstart_fn=warmstart_fn, @@ -3434,7 +3342,6 @@ def to_solver_model(self, model: Model, **kwargs: Any) -> cupdlpx.Model: stacklevel=2, ) cu_model = self._build_solver_model(model) - self._store_labels(model) self.solver_model = cu_model self.io_api = "direct" self.sense = model.sense @@ -3481,7 +3388,6 @@ def _build_solver_model(model: Model) -> cupdlpx.Model: def _run(self) -> Result: return self._solve( self.solver_model, - l_model=None, io_api=self.io_api, sense=self.sense, ) @@ -3489,7 +3395,6 @@ def _run(self) -> Result: def _solve( self, cu_model: cupdlpx.Model, - l_model: Model | None = None, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, @@ -3566,17 +3471,8 @@ def _solve( def get_solver_solution() -> Solution: objective = cu_model.ObjVal - - if l_model is not None: - vlabels = l_model.matrices.vlabels - clabels = l_model.matrices.clabels - else: - assert self._vlabels is not None and self._clabels is not None - vlabels = self._vlabels - clabels = self._clabels - - sol = values_to_lookup_array(np.asarray(cu_model.X, dtype=float), vlabels) - dual = values_to_lookup_array(np.asarray(cu_model.Pi, dtype=float), clabels) + sol = np.asarray(cu_model.X, dtype=float) + dual = np.asarray(cu_model.Pi, dtype=float) if cu_model.ModelSense == cupdlpx.PDLP.MAXIMIZE: dual = -dual From cd4fd02b3b980ce634da9c5c823f013464346e32 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 13 May 2026 16:19:14 +0200 Subject: [PATCH 19/27] docs: merge Solution ndarray release-notes bullets --- doc/release_notes.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 4ca171f7..af9b115f 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -8,9 +8,8 @@ Upcoming Version * Two-step solve workflow *for advanced users*: ``Model.prepare_solver(solver_name, ...)`` builds the native solver model without solving, and ``Model.run_solver()`` runs it. ``Model.apply_result(result=None)`` exposes the solution-mapping step and defaults to the state on ``model.solver``. Only use these methods if you want to control optimization on the solver instance directly. * Solver capabilities are declared as ``features: frozenset[SolverFeature]`` ClassVars on each ``Solver`` subclass; use ``Solver.supports(feature)``. ``SolverFeature`` is now exported from ``linopy`` (and from ``linopy.solvers``); ``linopy.solver_capabilities`` remains as a back-compat shim with a lazy ``SOLVER_REGISTRY`` mapping. * ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``. -* Direct-API solvers (HiGHS, Gurobi, Mosek, cuPDLPx) now return primals/duals in build order, so solutions are reconstructed positionally rather than by solver-side names — fixes label mapping when names are not set on the solver. +* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray``\ s of values in ``model.matrices.vlabels``/``clabels`` build order (length = ``n_vars`` / ``n_cons``); previously ``pd.Series`` keyed by variable/constraint name. Solutions are reconstructed positionally rather than by solver-side names — fixes label mapping when names are not set on the solver. Internal — code that introspected these must now pair with ``model.matrices.vlabels``/``clabels`` to interpret. * Per-solver translation helpers are unified under ``Solver._build_solver_model`` . The module-level ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` / ``to_cupdlpx`` (and their ``Model`` bindings) now emit ``DeprecationWarning`` with step-by-step migration to ``Solver.to_solver_model(model)`` or ``Model.prepare_solver(solver_name)``. -* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray``\ s of values in ``model.matrices.vlabels``/``clabels`` build order (length = ``n_vars`` / ``n_cons``). Previously they were ``pd.Series`` keyed by variable/constraint name. Internal -- code that introspected these must now pair with ``model.matrices.vlabels``/``clabels`` to interpret. * Increase speed of direct solver communication (~10x) in conversion functions like `to_highspy` through faster matrix creation (see below), leading to significant overall speed-up when setting `io_api="direct"`. * Add ``CSRConstraint``, a memory-efficient immutable constraint representation backed by scipy CSR sparse matrices. Provides up to 90% memory savings for constraints with many terms and 30–120x faster matrix generation for direct solver APIs. - Add ``freeze_constraints`` parameter to ``Model`` for globally storing constraints in CSR format on ``add_constraints``. From e3f77d411a51f1c86cd959779db276676fc3b0a2 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 15 May 2026 08:31:12 +0200 Subject: [PATCH 20/27] feat: add MIP dual_bound to SolverReport Adds dual_bound field to SolverReport, populated by HiGHS, Gurobi and Knitro. Declared via new SolverFeature.MIP_DUAL_BOUND_REPORT so tests gate assertions on capability instead of solver names. --- doc/release_notes.rst | 2 +- linopy/constants.py | 3 +++ linopy/solvers.py | 14 ++++++++++++-- test/test_optimization.py | 16 +++++++++++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index af9b115f..e042e6ae 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -7,7 +7,7 @@ Upcoming Version * Solver refactor: solver state now lives on a stateful ``Solver`` instance attached to ``Model.solver``. ``Model.solver_model`` and ``Model.solver_name`` become read-only properties delegating to ``model.solver`` (assigning anything other than ``None`` raises; setting ``None`` closes the solver). ``Model.solver_name`` may be ``None`` before a solve. The latter two properties may be deprecated in future versions. * Two-step solve workflow *for advanced users*: ``Model.prepare_solver(solver_name, ...)`` builds the native solver model without solving, and ``Model.run_solver()`` runs it. ``Model.apply_result(result=None)`` exposes the solution-mapping step and defaults to the state on ``model.solver``. Only use these methods if you want to control optimization on the solver instance directly. * Solver capabilities are declared as ``features: frozenset[SolverFeature]`` ClassVars on each ``Solver`` subclass; use ``Solver.supports(feature)``. ``SolverFeature`` is now exported from ``linopy`` (and from ``linopy.solvers``); ``linopy.solver_capabilities`` remains as a back-compat shim with a lazy ``SOLVER_REGISTRY`` mapping. -* ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``. +* ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, dual bound, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``; when possible also populate the MIP ``dual_bound``. * ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray``\ s of values in ``model.matrices.vlabels``/``clabels`` build order (length = ``n_vars`` / ``n_cons``); previously ``pd.Series`` keyed by variable/constraint name. Solutions are reconstructed positionally rather than by solver-side names — fixes label mapping when names are not set on the solver. Internal — code that introspected these must now pair with ``model.matrices.vlabels``/``clabels`` to interpret. * Per-solver translation helpers are unified under ``Solver._build_solver_model`` . The module-level ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` / ``to_cupdlpx`` (and their ``Model`` bindings) now emit ``DeprecationWarning`` with step-by-step migration to ``Solver.to_solver_model(model)`` or ``Model.prepare_solver(solver_name)``. * Increase speed of direct solver communication (~10x) in conversion functions like `to_highspy` through faster matrix creation (see below), leading to significant overall speed-up when setting `io_api="direct"`. diff --git a/linopy/constants.py b/linopy/constants.py index 7180f9ac..6b439a0f 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -282,6 +282,7 @@ class SolverReport: runtime: float | None = None mip_gap: float | None = None + dual_bound: float | None = None barrier_iterations: int | None = None simplex_iterations: int | None = None @@ -316,6 +317,8 @@ def __repr__(self) -> str: report_string += f"Runtime: {self.report.runtime:.2f}s\n" if self.report.mip_gap is not None: report_string += f"MIP gap: {self.report.mip_gap:.2e}\n" + if self.report.dual_bound is not None: + report_string += f"Dual bound: {self.report.dual_bound:.2e}\n" return ( f"Status: {self.status.status.value}\n" f"Termination condition: {self.status.termination_condition.value}\n" diff --git a/linopy/solvers.py b/linopy/solvers.py index 372cabfc..49f4abf3 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -76,6 +76,7 @@ class SolverFeature(Enum): SOS_CONSTRAINTS = auto() SEMI_CONTINUOUS_VARIABLES = auto() SOLVER_ATTRIBUTE_ACCESS = auto() + MIP_DUAL_BOUND_REPORT = auto() def _installed_version_in(pkg: str, spec: str) -> bool: @@ -999,6 +1000,7 @@ class Highs(Solver[None]): SolverFeature.READ_MODEL_FROM_FILE, SolverFeature.SOLUTION_FILE_NOT_NEEDED, SolverFeature.SEMI_CONTINUOUS_VARIABLES, + SolverFeature.MIP_DUAL_BOUND_REPORT, } ) @@ -1306,17 +1308,20 @@ def get_solver_solution() -> Solution: runtime: float | None = None mip_gap: float | None = None + dual_bound: float | None = None with contextlib.suppress(Exception): runtime = float(h.getRunTime()) with contextlib.suppress(Exception): mip_gap = float(h.getInfo().mip_gap) + with contextlib.suppress(Exception): + dual_bound = float(h.getInfo().mip_dual_bound) self.io_api = io_api return self._make_result( status, solution, solver_model=h, - report=SolverReport(runtime=runtime, mip_gap=mip_gap), + report=SolverReport(runtime=runtime, mip_gap=mip_gap, dual_bound=dual_bound), ) @@ -1343,6 +1348,7 @@ class Gurobi(Solver["gurobipy.Env | dict[str, Any] | None"]): SolverFeature.SOS_CONSTRAINTS, SolverFeature.SEMI_CONTINUOUS_VARIABLES, SolverFeature.SOLVER_ATTRIBUTE_ACCESS, + SolverFeature.MIP_DUAL_BOUND_REPORT, } ) @@ -1651,17 +1657,20 @@ def get_solver_solution() -> Solution: runtime: float | None = None mip_gap: float | None = None + dual_bound: float | None = None with contextlib.suppress(Exception): runtime = float(m.Runtime) with contextlib.suppress(Exception): mip_gap = float(m.MIPGap) + with contextlib.suppress(Exception): + dual_bound = float(m.ObjBound) self.io_api = io_api return self._make_result( status, solution, solver_model=m, - report=SolverReport(runtime=runtime, mip_gap=mip_gap), + report=SolverReport(runtime=runtime, mip_gap=mip_gap, dual_bound=dual_bound), ) @@ -2206,6 +2215,7 @@ class Knitro(Solver[None]): SolverFeature.LP_FILE_NAMES, SolverFeature.READ_MODEL_FROM_FILE, SolverFeature.SOLUTION_FILE_NOT_NEEDED, + SolverFeature.MIP_DUAL_BOUND_REPORT, } ) diff --git a/test/test_optimization.py b/test/test_optimization.py index addf777f..9b3eeb09 100644 --- a/test/test_optimization.py +++ b/test/test_optimization.py @@ -25,7 +25,13 @@ get_available_solvers_with_feature, solver_supports, ) -from linopy.solvers import _new_highspy_mps_layout, available_solvers, quadratic_solvers +from linopy.solvers import ( + SolverFeature, + _new_highspy_mps_layout, + _solver_class_for, + available_solvers, + quadratic_solvers, +) logger = logging.getLogger(__name__) @@ -770,6 +776,14 @@ def test_milp_model( assert condition == "optimal" assert ((milp_model.solution.y == 9) | (milp_model.solution.x == 0.5)).all() + solver_cls = _solver_class_for(solver) + if solver_cls is not None and solver_cls.supports( + SolverFeature.MIP_DUAL_BOUND_REPORT + ): + report = milp_model.solver.report + assert report is not None + assert report.dual_bound is not None + @pytest.mark.parametrize( "solver,io_api,explicit_coordinate_names", From b9cc9ac673236429a5b57578c25fb05f7e28a495 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 15 May 2026 08:31:26 +0200 Subject: [PATCH 21/27] Update CLAUDE.md --- CLAUDE.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1f696a0b..e5e3c182 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,22 +7,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Running Tests ```bash # Run all tests (excluding GPU tests by default) -pytest +uv run --extra dev --extra solvers pytest # Run tests with coverage -pytest --cov=./ --cov-report=xml linopy --doctest-modules test +uv run --extra dev --extra solvers pytest --cov=./ --cov-report=xml linopy --doctest-modules test # Run a specific test file -pytest test/test_model.py +uv run --extra dev --extra solvers pytest test/test_model.py # Run a specific test function -pytest test/test_model.py::test_model_creation +uv run --extra dev --extra solvers pytest test/test_model.py::test_model_creation # Run GPU tests (requires GPU hardware and cuPDLPx installation) -pytest --run-gpu +uv run --extra dev --extra solvers pytest --run-gpu # Run only GPU tests -pytest -m gpu --run-gpu +uv run --extra dev --extra solvers pytest -m gpu --run-gpu ``` **GPU Testing**: Tests that require GPU hardware (e.g., cuPDLPx solver) are automatically skipped by default since CI machines typically don't have GPUs. To run GPU tests locally, use the `--run-gpu` flag. The tests are automatically marked with `@pytest.mark.gpu` based on solver capabilities. @@ -30,17 +30,17 @@ pytest -m gpu --run-gpu ### Linting and Type Checking ```bash # Run linter (ruff) -ruff check . -ruff check --fix . # Auto-fix issues +uv run --extra dev ruff check . +uv run --extra dev ruff check --fix . # Auto-fix issues # Run formatter -ruff format . +uv run --extra dev ruff format . # Run type checker -mypy . +uv run --extra dev mypy . # Run all pre-commit hooks -pre-commit run --all-files +uv run --extra dev pre-commit run --all-files ``` ### Development Setup From 2ceffbe0a00a55e4cff7144e35b2244303a0686e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 06:48:35 +0000 Subject: [PATCH 22/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- linopy/solvers.py | 14 +++++++------- test/test_solution_lookup.py | 12 +++--------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 49f4abf3..89c213e7 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1321,7 +1321,9 @@ def get_solver_solution() -> Solution: status, solution, solver_model=h, - report=SolverReport(runtime=runtime, mip_gap=mip_gap, dual_bound=dual_bound), + report=SolverReport( + runtime=runtime, mip_gap=mip_gap, dual_bound=dual_bound + ), ) @@ -1670,7 +1672,9 @@ def get_solver_solution() -> Solution: status, solution, solver_model=m, - report=SolverReport(runtime=runtime, mip_gap=mip_gap, dual_bound=dual_bound), + report=SolverReport( + runtime=runtime, mip_gap=mip_gap, dual_bound=dual_bound + ), ) @@ -1988,11 +1992,7 @@ def get_solver_solution() -> Solution: cons = m.getConss(False) if len(cons) != 0: dual = np.array( - [ - m.getDualSolVal(c) - for c in cons - if c.name not in vars_to_ignore - ], + [m.getDualSolVal(c) for c in cons if c.name not in vars_to_ignore], dtype=float, ) else: diff --git a/test/test_solution_lookup.py b/test/test_solution_lookup.py index def4494b..d1b5336a 100644 --- a/test/test_solution_lookup.py +++ b/test/test_solution_lookup.py @@ -6,15 +6,11 @@ class TestValuesToLookupArray: def test_basic(self) -> None: - arr = values_to_lookup_array( - np.array([10.0, 20.0, 30.0]), np.array([0, 1, 2]) - ) + arr = values_to_lookup_array(np.array([10.0, 20.0, 30.0]), np.array([0, 1, 2])) np.testing.assert_array_equal(arr, [10.0, 20.0, 30.0]) def test_negative_labels_skipped(self) -> None: - arr = values_to_lookup_array( - np.array([nan, 10.0, 20.0]), np.array([-1, 0, 2]) - ) + arr = values_to_lookup_array(np.array([nan, 10.0, 20.0]), np.array([-1, 0, 2])) assert arr[0] == 10.0 assert np.isnan(arr[1]) assert arr[2] == 20.0 @@ -31,9 +27,7 @@ def test_only_negative_labels(self) -> None: assert len(arr) == 0 def test_explicit_size(self) -> None: - arr = values_to_lookup_array( - np.array([5.0, 7.0]), np.array([0, 2]), size=5 - ) + arr = values_to_lookup_array(np.array([5.0, 7.0]), np.array([0, 2]), size=5) assert len(arr) == 5 assert arr[0] == 5.0 assert arr[2] == 7.0 From 8115014d182273693eef3449de50645d8bf0fc1f Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 15 May 2026 09:11:34 +0200 Subject: [PATCH 23/27] perf: cache constraint labels to avoid matrix rebuild in apply_result Mirror VariableLabelIndex on the constraint side: add ConstraintBase.active_labels (cheap on both Constraint and CSRConstraint) and a cached ConstraintLabelIndex on Constraints. Model.apply_result and IIS extraction now read vlabels/clabels from these caches instead of self.matrices, which previously rebuilt the full A matrix on every solve. --- doc/release_notes.rst | 2 +- linopy/common.py | 37 ++++++++++++++ linopy/constraints.py | 30 ++++++++++++ linopy/matrices.py | 6 +-- linopy/model.py | 6 +-- test/test_constraint_label_index.py | 75 +++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 test/test_constraint_label_index.py diff --git a/doc/release_notes.rst b/doc/release_notes.rst index e042e6ae..1eef3bce 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -8,7 +8,7 @@ Upcoming Version * Two-step solve workflow *for advanced users*: ``Model.prepare_solver(solver_name, ...)`` builds the native solver model without solving, and ``Model.run_solver()`` runs it. ``Model.apply_result(result=None)`` exposes the solution-mapping step and defaults to the state on ``model.solver``. Only use these methods if you want to control optimization on the solver instance directly. * Solver capabilities are declared as ``features: frozenset[SolverFeature]`` ClassVars on each ``Solver`` subclass; use ``Solver.supports(feature)``. ``SolverFeature`` is now exported from ``linopy`` (and from ``linopy.solvers``); ``linopy.solver_capabilities`` remains as a back-compat shim with a lazy ``SOLVER_REGISTRY`` mapping. * ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, dual bound, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``; when possible also populate the MIP ``dual_bound``. -* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray``\ s of values in ``model.matrices.vlabels``/``clabels`` build order (length = ``n_vars`` / ``n_cons``); previously ``pd.Series`` keyed by variable/constraint name. Solutions are reconstructed positionally rather than by solver-side names — fixes label mapping when names are not set on the solver. Internal — code that introspected these must now pair with ``model.matrices.vlabels``/``clabels`` to interpret. +* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray``\ s of values in ``model.matrices.vlabels``/``clabels`` build order (length = ``n_vars`` / ``n_cons``); previously ``pd.Series`` keyed by variable/constraint name. Solutions are reconstructed positionally rather than by solver-side names — fixes label mapping when names are not set on the solver. Internal — code that introspected these must now pair with ``model.matrices.vlabels``/``clabels`` to interpret. Solution mapping reads labels from a cached ``ConstraintLabelIndex`` on ``Model.constraints`` and no longer triggers a constraint-matrix rebuild. * Per-solver translation helpers are unified under ``Solver._build_solver_model`` . The module-level ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` / ``to_cupdlpx`` (and their ``Model`` bindings) now emit ``DeprecationWarning`` with step-by-step migration to ``Solver.to_solver_model(model)`` or ``Model.prepare_solver(solver_name)``. * Increase speed of direct solver communication (~10x) in conversion functions like `to_highspy` through faster matrix creation (see below), leading to significant overall speed-up when setting `io_api="direct"`. * Add ``CSRConstraint``, a memory-efficient immutable constraint representation backed by scipy CSR sparse matrices. Provides up to 90% memory savings for constraints with many terms and 30–120x faster matrix generation for direct solver APIs. diff --git a/linopy/common.py b/linopy/common.py index 8afb4f88..f782cc9c 100644 --- a/linopy/common.py +++ b/linopy/common.py @@ -999,6 +999,43 @@ def invalidate(self) -> None: self.__dict__.pop("label_to_pos", None) +class ConstraintLabelIndex: + """ + Index for O(1) mapping between constraint labels and dense positions. + + Mirrors VariableLabelIndex on the constraint side, but without building + the full constraint matrix — only labels and the row mask are computed. + """ + + def __init__(self, constraints: Any) -> None: + self._constraints = constraints + + @cached_property + def clabels(self) -> np.ndarray: + """Active constraint labels in build order, shape (n_active_cons,).""" + label_lists = [c.active_labels() for c in self._constraints.data.values()] + return ( + np.concatenate(label_lists) if label_lists else np.array([], dtype=np.intp) + ) + + @cached_property + def label_to_pos(self) -> np.ndarray: + """Mapping from constraint label to dense position, shape (_cCounter,).""" + clabels = self.clabels + n = self._constraints.model._cCounter + label_to_pos = np.full(n, -1, dtype=np.intp) + label_to_pos[clabels] = np.arange(len(clabels), dtype=np.intp) + return label_to_pos + + @property + def n_active_cons(self) -> int: + return len(self.clabels) + + def invalidate(self) -> None: + self.__dict__.pop("clabels", None) + self.__dict__.pop("label_to_pos", None) + + def get_label_position( obj: Any, values: int | np.ndarray, diff --git a/linopy/constraints.py b/linopy/constraints.py index 6aab4902..0b1973cf 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -32,6 +32,7 @@ from linopy import expressions, variables from linopy.common import ( + ConstraintLabelIndex, LabelPositionIndex, LocIndexer, VariableLabelIndex, @@ -212,6 +213,10 @@ def to_matrix_with_rhs( the RHS/sense vectors are needed. """ + @abstractmethod + def active_labels(self) -> np.ndarray: + """Active constraint labels in build order, without building the CSR.""" + def __getitem__( self, selector: str | int | slice | list | tuple | dict ) -> Constraint: @@ -865,6 +870,9 @@ def to_matrix_with_rhs( sense = np.array([s[0] for s in self._sign]) return self._csr, self._con_labels, self._rhs, sense + def active_labels(self) -> np.ndarray: + return self._con_labels + def sanitize_zeros(self) -> CSRConstraint: """Remove terms with zero or near-zero coefficients (mutates in-place).""" self._csr.data[np.abs(self._csr.data) <= 1e-10] = 0 @@ -1222,6 +1230,18 @@ def to_matrix( csr.sum_duplicates() return csr, con_labels + def active_labels(self) -> np.ndarray: + labels_flat = self.labels.values.ravel() + vars_vals = self.vars.values + n_rows = len(labels_flat) + vars_2d = ( + vars_vals.reshape(n_rows, -1) + if n_rows > 0 + else vars_vals.reshape(0, max(1, vars_vals.size)) + ) + row_mask = (labels_flat != -1) & (vars_2d != -1).any(axis=1) + return labels_flat[row_mask] + def to_matrix_with_rhs( self, label_index: VariableLabelIndex ) -> tuple[scipy.sparse.csr_array, np.ndarray, np.ndarray, np.ndarray]: @@ -1427,6 +1447,7 @@ class Constraints: data: dict[str, ConstraintBase] model: Model _label_position_index: LabelPositionIndex | None = None + _constraint_label_index: ConstraintLabelIndex | None = None dataset_attrs = ["labels", "coeffs", "vars", "sign", "rhs"] dataset_names = [ @@ -1548,6 +1569,15 @@ def _invalidate_label_position_index(self) -> None: """Invalidate the label position index cache.""" if self._label_position_index is not None: self._label_position_index.invalidate() + if self._constraint_label_index is not None: + self._constraint_label_index.invalidate() + + @property + def label_index(self) -> ConstraintLabelIndex: + """Index for O(1) label->position mapping and compact clabels array.""" + if self._constraint_label_index is None: + self._constraint_label_index = ConstraintLabelIndex(self) + return self._constraint_label_index @property def labels(self) -> Dataset: diff --git a/linopy/matrices.py b/linopy/matrices.py index 1fb59344..e694a720 100644 --- a/linopy/matrices.py +++ b/linopy/matrices.py @@ -81,18 +81,16 @@ def _build_cons(self) -> None: label_index = m.variables.label_index csrs = [] - clabels_list = [] b_list = [] sense_list = [] for c in m.constraints.data.values(): - csr, con_labels, b, sense = c.to_matrix_with_rhs(label_index) + csr, _, b, sense = c.to_matrix_with_rhs(label_index) csrs.append(csr) - clabels_list.append(con_labels) b_list.append(b) sense_list.append(sense) self.A = cast(scipy.sparse.csr_array, scipy.sparse.vstack(csrs, format="csr")) - self.clabels = np.concatenate(clabels_list) + self.clabels = m.constraints.label_index.clabels self.b = np.concatenate(b_list) if b_list else np.array([]) self.sense = ( np.concatenate(sense_list) if sense_list else np.array([], dtype=object) diff --git a/linopy/model.py b/linopy/model.py index bf14137e..584d0f3a 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1868,14 +1868,14 @@ def apply_result(self, result: Result | None = None) -> tuple[str, str]: return status_value, termination_condition primal = result.solution.primal - sol_arr = values_to_lookup_array(primal, self.matrices.vlabels) + sol_arr = values_to_lookup_array(primal, self.variables.label_index.vlabels) for _, var in self.variables.items(): vals = lookup_vals(sol_arr, np.ravel(var.labels)) var.solution = xr.DataArray(vals.reshape(var.labels.shape), var.coords) if len(result.solution.dual): dual = result.solution.dual - dual_arr = values_to_lookup_array(dual, self.matrices.clabels) + dual_arr = values_to_lookup_array(dual, self.constraints.label_index.clabels) for _, con in self.constraints.items(): vals = lookup_vals(dual_arr, np.ravel(con.labels)) con.dual = xr.DataArray( @@ -2018,7 +2018,7 @@ def _compute_infeasibilities_xpress(self, solver_model: Any) -> list[int]: if solver_model.attributes.numiis == 0: return [] - clabels = self.matrices.clabels + clabels = self.constraints.label_index.clabels constraint_position_map = {} for position, constraint_obj in enumerate(solver_model.getConstraint()): if 0 <= position < len(clabels): diff --git a/test/test_constraint_label_index.py b/test/test_constraint_label_index.py new file mode 100644 index 00000000..554882af --- /dev/null +++ b/test/test_constraint_label_index.py @@ -0,0 +1,75 @@ +import numpy as np +import pandas as pd +import pytest + +import linopy +from linopy.constraints import Constraint + + +@pytest.fixture +def model_with_mask() -> linopy.Model: + m = linopy.Model() + coords = pd.Index(range(5), name="i") + mask = np.array([True, False, True, True, False]) + x = m.add_variables(lower=0, coords=[coords], name="x") + y = m.add_variables(lower=0, coords=[coords], name="y") + m.add_constraints(x + y >= 1, name="c_xy", mask=mask) + m.add_constraints(x.sum() + y.sum() <= 100, name="c_sum") + m.add_objective(x.sum() + 2 * y.sum()) + return m + + +def test_clabels_parity_with_matrices(model_with_mask: linopy.Model) -> None: + expected = model_with_mask.matrices.clabels + actual = model_with_mask.constraints.label_index.clabels + np.testing.assert_array_equal(actual, expected) + + +def test_apply_result_does_not_build_matrix( + monkeypatch: pytest.MonkeyPatch, model_with_mask: linopy.Model +) -> None: + calls = {"n": 0} + original = Constraint._matrix_export_data + + def counting(self, label_index): # type: ignore[no-untyped-def] + calls["n"] += 1 + return original(self, label_index) + + monkeypatch.setattr(Constraint, "_matrix_export_data", counting) + + model_with_mask.solve("highs") + + assert model_with_mask.status == "ok" + # one build for solver input is fine; the post-solve mapping must not add more + n_after_solve = calls["n"] + model_with_mask.apply_result() + assert calls["n"] == n_after_solve + + +def test_label_index_invalidated_on_add(model_with_mask: linopy.Model) -> None: + first = model_with_mask.constraints.label_index.clabels.copy() + x = model_with_mask.variables["x"] + model_with_mask.add_constraints(x.sum() >= 0, name="c_extra") + second = model_with_mask.constraints.label_index.clabels + assert len(second) == len(first) + 1 + + +def test_label_index_invalidated_on_remove(model_with_mask: linopy.Model) -> None: + before = len(model_with_mask.constraints.label_index.clabels) + removed = len(model_with_mask.constraints["c_sum"].active_labels()) + model_with_mask.constraints.remove("c_sum") + after = len(model_with_mask.constraints.label_index.clabels) + assert after == before - removed + + +def test_apply_result_correctness_with_mask(model_with_mask: linopy.Model) -> None: + model_with_mask.solve("highs") + assert model_with_mask.status == "ok" + x_sol = model_with_mask.variables["x"].solution.values + y_sol = model_with_mask.variables["y"].solution.values + assert np.isfinite(x_sol).all() + assert np.isfinite(y_sol).all() + dual = model_with_mask.constraints["c_xy"].dual.values + mask = np.array([True, False, True, True, False]) + assert np.isfinite(dual[mask]).all() + assert np.isnan(dual[~mask]).all() From 3f5765b21c1ffc0f28b2fecacf0085706012df76 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 15 May 2026 09:58:21 +0200 Subject: [PATCH 24/27] fix: label-indexed Solution.primal/dual, robust to solver iteration order Restore the 43a82aa contract: Solution.primal and Solution.dual are dense ndarrays indexed by linopy label with NaN at gaps. Each solver builds the label-indexed form itself; apply_result shrinks to a direct lookup_vals. This fixes two classes of bugs the intermediate 'primal/dual in build order' contract couldn't handle: - File-based LP solvers iterate variables in objective-encounter order, which can interleave entries from multiple add_variables calls (e.g. non-aligned coords producing x0, x10, x1, x11, ...). Positional alignment with vlabels broke for every test exercising masked or non-aligned models on CBC / GLPK / Cplex / SCIP / Xpress. - CPLEX drops entirely-unconstrained unused variables on LP read, so get_values() returns fewer values than the linopy model has labels; positional alignment errored at apply_result. Implementation: - _solution_from_names(values, names): file-based path. Parses linopy labels via the existing _names_to_labels helper and scatters values into a label-indexed array. Used by CBC, GLPK, Cplex, SCIP, Xpress, Knitro, COPT, MindOpt, and the from_file branches of Highs/Gurobi. - _solution_from_labels(values, labels): direct-API path. Uses cached vlabels/clabels populated on the Solver instance at to_solver_model time. Used by Highs, Gurobi, Mosek, cuPDLPx. - Highs and Gurobi from_file branches converge on _solution_from_names, replacing the inline keep + argsort pattern. --- doc/release_notes.rst | 2 +- linopy/common.py | 24 ---- linopy/constants.py | 6 +- linopy/constraints.py | 9 ++ linopy/model.py | 16 +-- linopy/solvers.py | 233 +++++++++++++++++++++++++++++------ test/test_solution_lookup.py | 54 +++----- test/test_solvers.py | 33 ++++- 8 files changed, 262 insertions(+), 115 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 1eef3bce..f2a089b7 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -8,7 +8,7 @@ Upcoming Version * Two-step solve workflow *for advanced users*: ``Model.prepare_solver(solver_name, ...)`` builds the native solver model without solving, and ``Model.run_solver()`` runs it. ``Model.apply_result(result=None)`` exposes the solution-mapping step and defaults to the state on ``model.solver``. Only use these methods if you want to control optimization on the solver instance directly. * Solver capabilities are declared as ``features: frozenset[SolverFeature]`` ClassVars on each ``Solver`` subclass; use ``Solver.supports(feature)``. ``SolverFeature`` is now exported from ``linopy`` (and from ``linopy.solvers``); ``linopy.solver_capabilities`` remains as a back-compat shim with a lazy ``SOLVER_REGISTRY`` mapping. * ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, dual bound, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``; when possible also populate the MIP ``dual_bound``. -* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray``\ s of values in ``model.matrices.vlabels``/``clabels`` build order (length = ``n_vars`` / ``n_cons``); previously ``pd.Series`` keyed by variable/constraint name. Solutions are reconstructed positionally rather than by solver-side names — fixes label mapping when names are not set on the solver. Internal — code that introspected these must now pair with ``model.matrices.vlabels``/``clabels`` to interpret. Solution mapping reads labels from a cached ``ConstraintLabelIndex`` on ``Model.constraints`` and no longer triggers a constraint-matrix rebuild. +* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray`` lookup arrays keyed by integer model labels (length = ``max_label + 1``, ``NaN`` for unfilled positions); previously ``pd.Series`` keyed by variable/constraint name. Each solver is responsible for emitting the label-indexed form — direct-API solvers via cached ``_vlabels``/``_clabels`` populated at ``to_solver_model`` time, file-based solvers via the shared ``_solution_from_names`` helper which parses linopy labels from solver-side names. Fixes solution mapping for file-based solvers that may iterate variables in a different order than linopy's build order or drop unused variables entirely. Internal — code that introspected these must use ``np.ndarray`` semantics. Solution mapping reads labels from a cached ``ConstraintLabelIndex`` on ``Model.constraints`` and no longer triggers a constraint-matrix rebuild. * Per-solver translation helpers are unified under ``Solver._build_solver_model`` . The module-level ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` / ``to_cupdlpx`` (and their ``Model`` bindings) now emit ``DeprecationWarning`` with step-by-step migration to ``Solver.to_solver_model(model)`` or ``Model.prepare_solver(solver_name)``. * Increase speed of direct solver communication (~10x) in conversion functions like `to_highspy` through faster matrix creation (see below), leading to significant overall speed-up when setting `io_api="direct"`. * Add ``CSRConstraint``, a memory-efficient immutable constraint representation backed by scipy CSR sparse matrices. Provides up to 90% memory savings for constraints with many terms and 30–120x faster matrix generation for direct solver APIs. diff --git a/linopy/common.py b/linopy/common.py index f782cc9c..e9a38d29 100644 --- a/linopy/common.py +++ b/linopy/common.py @@ -1577,27 +1577,3 @@ def values_to_lookup_array( arr = np.full(size, nan, dtype=float) arr[labels[mask]] = values[mask] return arr - - -def lookup_vals(arr: np.ndarray, idx: np.ndarray) -> np.ndarray: - """ - Look up values from a dense array by integer labels. - - Negative labels and labels beyond the array length map to NaN. - - Parameters - ---------- - arr : np.ndarray - Dense lookup array (e.g. from :func:`values_to_lookup_array`). - idx : np.ndarray - Integer label indices. - - Returns - ------- - np.ndarray - Array of looked-up values with the same shape as *idx*. - """ - valid = (idx >= 0) & (idx < len(arr)) - vals = np.full(idx.shape, nan) - vals[valid] = arr[idx[valid]] - return vals diff --git a/linopy/constants.py b/linopy/constants.py index 6b439a0f..86af18ce 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -265,8 +265,10 @@ class Solution: """ Solution returned by the solver. - ``primal`` and ``dual`` are values in ``vlabels``/``clabels`` build order - -- ``primal[i]`` is the value for variable label ``vlabels[i]``. + ``primal`` and ``dual`` are dense float arrays indexed by linopy label: + ``primal[label]`` is the value for variable ``label``, with ``NaN`` where + no value is available (masked labels, vars dropped by the solver, etc.). + Each solver is responsible for emitting arrays in this label-indexed form. """ primal: np.ndarray = field(default_factory=lambda: np.array([], dtype=float)) diff --git a/linopy/constraints.py b/linopy/constraints.py index 0b1973cf..b74dee5c 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -144,6 +144,11 @@ def is_assigned(self) -> bool: def labels(self) -> DataArray: """Get the labels DataArray.""" + @property + @abstractmethod + def range(self) -> tuple[int, int]: + """Return the label range of the constraint.""" + @property @abstractmethod def coeffs(self) -> DataArray: @@ -566,6 +571,10 @@ def attrs(self) -> dict[str, Any]: d["label_range"] = (self._cindex, self._cindex + self.full_size) return d + @property + def coords(self) -> DatasetCoordinates: + return Dataset(coords={c.name: c for c in self._coords}).coords + @property def dims(self) -> Frozen[Hashable, int]: d: dict[Hashable, int] = {c.name: len(c) for c in self._coords} diff --git a/linopy/model.py b/linopy/model.py index 584d0f3a..d77641e2 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -32,11 +32,9 @@ assign_multiindex_safe, best_int, broadcast_mask, - lookup_vals, maybe_replace_signs, replace_by_map, to_path, - values_to_lookup_array, ) from linopy.constants import ( GREATER_EQUAL, @@ -1763,6 +1761,7 @@ def solve( slice_size=slice_size, progress=progress, ) + solver._cache_model_sizes(self) solver.solve_problem_from_file( problem_fn=to_path(problem_fn), solution_fn=to_path(solution_fn), @@ -1868,18 +1867,19 @@ def apply_result(self, result: Result | None = None) -> tuple[str, str]: return status_value, termination_condition primal = result.solution.primal - sol_arr = values_to_lookup_array(primal, self.variables.label_index.vlabels) for _, var in self.variables.items(): - vals = lookup_vals(sol_arr, np.ravel(var.labels)) - var.solution = xr.DataArray(vals.reshape(var.labels.shape), var.coords) + start, end = var.range + var.solution = xr.DataArray( + primal[start:end].reshape(var.shape), var.coords + ) if len(result.solution.dual): dual = result.solution.dual - dual_arr = values_to_lookup_array(dual, self.constraints.label_index.clabels) for _, con in self.constraints.items(): - vals = lookup_vals(dual_arr, np.ravel(con.labels)) + start, end = con.range + coords = {dim: con.coords[dim] for dim in con.coord_dims} con.dual = xr.DataArray( - vals.reshape(con.labels.shape), con.labels.coords + dual[start:end].reshape(con.shape), coords, dims=con.coord_dims ) return status_value, termination_condition diff --git a/linopy/solvers.py b/linopy/solvers.py index 89c213e7..33e210a8 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -32,7 +32,7 @@ from scipy.sparse import tril, triu import linopy.io -from linopy.common import count_initial_letters +from linopy.common import count_initial_letters, values_to_lookup_array from linopy.constants import ( SOS_DIM_ATTR, SOS_TYPE_ATTR, @@ -57,11 +57,52 @@ def _parse_int_label(name: str) -> int: def _names_to_labels(names: Any) -> np.ndarray: """Vectorised conversion of solver-provided names to integer labels.""" - return np.fromiter( - (_parse_int_label(n) for n in names), dtype=np.int64, count=len(names) + if len(names) == 0: + return np.array([], dtype=np.int64) + index = pd.Index(names) + if pd.api.types.is_integer_dtype(index.dtype): + return index.to_numpy(dtype=np.int64) + string_index = index.astype(str) + cutoff = count_initial_letters(str(string_index[0])) + try: + return string_index.str[cutoff:].astype(np.int64).to_numpy(dtype=np.int64) + except (TypeError, ValueError): + try: + return ( + string_index.str.replace(r".*#", "", regex=True) + .astype(np.int64) + .to_numpy(dtype=np.int64) + ) + except (TypeError, ValueError): + return np.fromiter( + (_parse_int_label(n) for n in names), dtype=np.int64, count=len(names) + ) + + +def _solution_from_names(values: np.ndarray, names: Any, size: int) -> np.ndarray: + """ + Build a label-indexed dense solution array of length ``size`` from + solver-side names. Used by paths where the solver may iterate in arbitrary + order or drop unused entities (file-based LP solvers, the ``from_file`` + paths of Highs/Gurobi). + """ + if not size: + return np.array([], dtype=float) + return values_to_lookup_array( + np.asarray(values, dtype=float), _names_to_labels(names), size=size ) +def _solution_from_labels( + values: np.ndarray, labels: np.ndarray | None, size: int +) -> np.ndarray: + """Scatter solver-side values into a label-indexed dense array of length ``size``.""" + if not size: + return np.array([], dtype=float) + assert labels is not None + return values_to_lookup_array(np.asarray(values, dtype=float), labels, size=size) + + class SolverFeature(Enum): """Enumeration of all solver capabilities tracked by linopy.""" @@ -386,6 +427,14 @@ def __init__( self.sense: str | None = None self.env: Any = None self._env_stack: contextlib.ExitStack | None = None + # cached at to_solver_model time so direct-API solvers can build + # label-indexed Solution.primal/dual without re-fetching the model. + self._vlabels: np.ndarray | None = None + self._clabels: np.ndarray | None = None + # Total label counts (model._xCounter / _cCounter), sized so that + # masked label slots are preserved in the dense Solution arrays. + self._n_vars: int = 0 + self._n_cons: int = 0 if self.solver_name.value not in available_solvers: msg = f"Solver package for '{self.solver_name.value}' is not installed. Please install first to initialize solver instance." @@ -394,6 +443,18 @@ def __init__( def to_solver_model(self, model: Model, **kwargs: Any) -> Any: raise NotImplementedError + def _cache_model_labels(self, model: Model) -> None: + """Cache vlabels/clabels and total label counts for label-indexed solutions.""" + self._vlabels = model.variables.label_index.vlabels + self._clabels = model.constraints.label_index.clabels + self._n_vars = model._xCounter + self._n_cons = model._cCounter + + def _cache_model_sizes(self, model: Model) -> None: + """Cache total label counts only (file-based solvers parse names).""" + self._n_vars = model._xCounter + self._n_cons = model._cCounter + def update_solver_model(self, model: Model, **kwargs: Any) -> None: raise NotImplementedError @@ -759,8 +820,16 @@ def get_solver_solution() -> Solution: sol_df = df[variables_b] dual_df = df[~variables_b] - sol = sol_df[2].to_numpy(dtype=float) - dual = dual_df[3].to_numpy(dtype=float) + sol = _solution_from_names( + sol_df[2].to_numpy(dtype=float), + sol_df.index.tolist(), + self._n_vars, + ) + dual = _solution_from_names( + dual_df[3].to_numpy(dtype=float), + dual_df.index.tolist(), + self._n_cons, + ) return Solution(sol, dual, objective) solution = self.safe_get_solution(status=status, func=get_solver_solution) @@ -949,10 +1018,12 @@ 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 = ( + dual = _solution_from_names( pd.to_numeric(dual_["Marginal"], "coerce") .fillna(0) - .to_numpy(dtype=float) + .to_numpy(dtype=float), + dual_.index.tolist(), + self._n_cons, ) else: logger.warning("Dual values of MILP couldn't be parsed") @@ -960,7 +1031,11 @@ def get_solver_solution() -> Solution: sol_io = io.StringIO("".join(read_until_break(f))[:-2]) sol_df = pd.read_fwf(sol_io)[1:].set_index("Column name") - sol = sol_df["Activity"].astype(float).to_numpy() + sol = _solution_from_names( + sol_df["Activity"].astype(float).to_numpy(), + sol_df.index.tolist(), + self._n_vars, + ) f.close() return Solution(sol, dual, objective) @@ -1040,6 +1115,7 @@ def to_solver_model( self.solver_model = h self.io_api = "direct" self.sense = model.sense + self._cache_model_labels(model) return h @staticmethod @@ -1293,14 +1369,11 @@ def get_solver_solution() -> Solution: dual = np.asarray(solution.row_dual, dtype=float) if from_file: lp = h.getLp() - if len(lp.col_names_): - vlabels = _names_to_labels(lp.col_names_) - keep = vlabels >= 0 - sol = sol[keep][np.argsort(vlabels[keep])] - if len(lp.row_names_): - clabels = _names_to_labels(lp.row_names_) - keep = clabels >= 0 - dual = dual[keep][np.argsort(clabels[keep])] + sol = _solution_from_names(sol, lp.col_names_, self._n_vars) + dual = _solution_from_names(dual, lp.row_names_, self._n_cons) + else: + sol = _solution_from_labels(sol, self._vlabels, self._n_vars) + dual = _solution_from_labels(dual, self._clabels, self._n_cons) return Solution(sol, dual, objective) solution = self.safe_get_solution(status=status, func=get_solver_solution) @@ -1390,6 +1463,7 @@ def to_solver_model( self.solver_model = m self.io_api = "direct" self.sense = model.sense + self._cache_model_labels(model) return m @staticmethod @@ -1636,18 +1710,24 @@ def get_solver_solution() -> Solution: vars_ = m.getVars() sol = np.array([v.X for v in vars_], dtype=float) - if from_file and len(vars_): - vlabels = _names_to_labels([v.VarName for v in vars_]) - keep = vlabels >= 0 - sol = sol[keep][np.argsort(vlabels[keep])] + if from_file: + sol = _solution_from_names( + sol, [v.VarName for v in vars_], self._n_vars + ) + else: + sol = _solution_from_labels(sol, self._vlabels, self._n_vars) try: constrs = m.getConstrs() dual = np.array([c.Pi for c in constrs], dtype=float) - if from_file and len(constrs): - clabels = _names_to_labels([c.ConstrName for c in constrs]) - keep = clabels >= 0 - dual = dual[keep][np.argsort(clabels[keep])] + if from_file: + dual = _solution_from_names( + dual, + [c.ConstrName for c in constrs], + self._n_cons, + ) + else: + dual = _solution_from_labels(dual, self._clabels, self._n_cons) except AttributeError: logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) @@ -1826,10 +1906,18 @@ def get_solver_solution() -> Solution: objective = m.solution.get_objective_value() - solution = np.asarray(m.solution.get_values(), dtype=float) + solution = _solution_from_names( + np.asarray(m.solution.get_values(), dtype=float), + m.variables.get_names(), + self._n_vars, + ) try: - dual = np.asarray(m.solution.get_dual_values(), dtype=float) + dual = _solution_from_names( + np.asarray(m.solution.get_dual_values(), dtype=float), + m.linear_constraints.get_names(), + self._n_cons, + ) except Exception: logger.warning( "Dual values not available (e.g. barrier solution without crossover)" @@ -1984,16 +2072,20 @@ def get_solver_solution() -> Solution: vars_to_ignore = {"quadobjvar", "qmatrixvar", "quadobj", "qmatrix"} s = m.getSols()[0] - sol = np.array( - [s[v] for v in m.getVars() if v.name not in vars_to_ignore], - dtype=float, + kept_vars = [v for v in m.getVars() if v.name not in vars_to_ignore] + sol = _solution_from_names( + np.array([s[v] for v in kept_vars], dtype=float), + [v.name for v in kept_vars], + self._n_vars, ) cons = m.getConss(False) if len(cons) != 0: - dual = np.array( - [m.getDualSolVal(c) for c in cons if c.name not in vars_to_ignore], - dtype=float, + kept_cons = [c for c in 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?)") @@ -2164,7 +2256,11 @@ def solve_problem_from_file( def get_solver_solution() -> Solution: objective = m.attributes.objval - sol = np.asarray(m.getSolution(), dtype=float) + sol = _solution_from_names( + np.asarray(m.getSolution(), dtype=float), + [v.name for v in m.getVariable()], + self._n_vars, + ) try: if m.attributes.rows == 0: @@ -2174,7 +2270,11 @@ def get_solver_solution() -> Solution: _dual = m.getDuals() except AttributeError: # Fallback to old API _dual = m.getDual() - dual = np.asarray(_dual, dtype=float) + dual = _solution_from_names( + np.asarray(_dual, dtype=float), + [c.name for c in m.getConstraint()], + self._n_cons, + ) except (xpress.SolverError, xpress.ModelError, SystemError): logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) @@ -2401,6 +2501,9 @@ def get_solver_solution() -> Solution: knitro.KN_get_number_vars, knitro.KN_get_var_primal_values, ) + n_vars = int(knitro.KN_get_number_vars(kc)) + var_names = [knitro.KN_get_var_names(kc, i) for i in range(n_vars)] + sol = _solution_from_names(sol, var_names, self._n_vars) try: dual = self._extract_values( @@ -2408,6 +2511,9 @@ def get_solver_solution() -> Solution: knitro.KN_get_number_cons, knitro.KN_get_con_dual_values, ) + n_cons = int(knitro.KN_get_number_cons(kc)) + con_names = [knitro.KN_get_con_names(kc, i) for i in range(n_cons)] + dual = _solution_from_names(dual, con_names, self._n_cons) except Exception: logger.warning("Dual values couldn't be parsed") dual = np.array([], dtype=float) @@ -2554,6 +2660,7 @@ def to_solver_model( self.solver_model = m self.io_api = "direct" self.sense = model.sense + self._cache_model_labels(model) return m @staticmethod @@ -2705,6 +2812,7 @@ def solve_problem_from_file( basis_fn=basis_fn, io_api=io_api, sense=sense, + from_file=True, ) def _solve( @@ -2716,6 +2824,7 @@ def _solve( basis_fn: Path | None, io_api: str | None, sense: str | None, + from_file: bool = False, ) -> Result: """ Solve a linear problem from a Mosek task object. @@ -2884,10 +2993,30 @@ def _solve( def get_solver_solution() -> Solution: objective = m.getprimalobj(soltype) - sol = np.asarray(m.getxx(soltype), dtype=float) + sol_values = np.asarray(m.getxx(soltype), dtype=float) + if from_file: + sol = _solution_from_names( + sol_values, + [m.getvarname(i) for i in range(m.getnumvar())], + self._n_vars, + ) + else: + sol = _solution_from_labels(sol_values, self._vlabels, self._n_vars) try: - dual = np.asarray(m.gety(soltype), dtype=float) + dual_values = np.asarray(m.gety(soltype), dtype=float) + if from_file: + dual = _solution_from_names( + dual_values, + [m.getconname(i) for i in range(m.getnumcon())], + self._n_cons, + ) + else: + dual = _solution_from_labels( + dual_values, + self._clabels, + self._n_cons, + ) except (mosek.Error, AttributeError): logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) @@ -3036,10 +3165,20 @@ def get_solver_solution() -> Solution: # TODO: check if this suffices objective = m.BestObj if m.ismip else m.LpObjVal - sol = np.array([v.x for v in m.getVars()], dtype=float) + vars_ = m.getVars() + sol = _solution_from_names( + np.array([v.x for v in vars_], dtype=float), + [v.name for v in vars_], + self._n_vars, + ) try: - dual = np.array([c.pi for c in m.getConstrs()], dtype=float) + cons = m.getConstrs() + dual = _solution_from_names( + np.array([c.pi for c in cons], dtype=float), + [c.name for c in cons], + self._n_cons, + ) except (coptpy.CoptError, AttributeError): logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) @@ -3192,10 +3331,20 @@ def solve_problem_from_file( def get_solver_solution() -> Solution: objective = m.objval - sol = np.array([v.X for v in m.getVars()], dtype=float) + vars_ = m.getVars() + sol = _solution_from_names( + np.array([v.X for v in vars_], dtype=float), + [v.VarName for v in vars_], + self._n_vars, + ) try: - dual = np.array([c.DualSoln for c in m.getConstrs()], dtype=float) + cons = m.getConstrs() + dual = _solution_from_names( + np.array([c.DualSoln for c in cons], dtype=float), + [c.ConstrName for c in cons], + self._n_cons, + ) except (mindoptpy.MindoptError, AttributeError): logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) @@ -3355,6 +3504,7 @@ def to_solver_model(self, model: Model, **kwargs: Any) -> cupdlpx.Model: self.solver_model = cu_model self.io_api = "direct" self.sense = model.sense + self._cache_model_labels(model) return cu_model @staticmethod @@ -3487,6 +3637,9 @@ def get_solver_solution() -> Solution: if cu_model.ModelSense == cupdlpx.PDLP.MAXIMIZE: dual = -dual + sol = _solution_from_labels(sol, self._vlabels, self._n_vars) + dual = _solution_from_labels(dual, self._clabels, self._n_cons) + return Solution(sol, dual, objective) solution = self.safe_get_solution(status=status, func=get_solver_solution) diff --git a/test/test_solution_lookup.py b/test/test_solution_lookup.py index d1b5336a..3f6475a8 100644 --- a/test/test_solution_lookup.py +++ b/test/test_solution_lookup.py @@ -1,7 +1,8 @@ import numpy as np from numpy import nan -from linopy.common import lookup_vals, values_to_lookup_array +from linopy.common import values_to_lookup_array +from linopy.solvers import _solution_from_names class TestValuesToLookupArray: @@ -36,41 +37,18 @@ def test_explicit_size(self) -> None: assert np.isnan(arr[4]) -class TestLookupVals: - def test_basic(self) -> None: - arr = np.array([10.0, 20.0, 30.0]) - idx = np.array([0, 1, 2]) - result = lookup_vals(arr, idx) - np.testing.assert_array_equal(result, [10.0, 20.0, 30.0]) - - def test_negative_labels_become_nan(self) -> None: - arr = np.array([10.0, 20.0]) - idx = np.array([0, -1, 1, -1]) - result = lookup_vals(arr, idx) - assert result[0] == 10.0 - assert np.isnan(result[1]) - assert result[2] == 20.0 - assert np.isnan(result[3]) - - def test_out_of_range_labels_become_nan(self) -> None: - arr = np.array([10.0, 20.0]) - idx = np.array([0, 1, 999]) - result = lookup_vals(arr, idx) - assert result[0] == 10.0 - assert result[1] == 20.0 - assert np.isnan(result[2]) - - def test_all_negative(self) -> None: - arr = np.array([10.0]) - idx = np.array([-1, -1, -1]) - result = lookup_vals(arr, idx) - assert all(np.isnan(result)) +class TestSolutionFromNames: + def test_default_names(self) -> None: + arr = _solution_from_names( + np.array([1.0, 2.0, 3.0]), ["x2", "x0", "x1"], size=4 + ) + np.testing.assert_array_equal(arr[:3], [2.0, 3.0, 1.0]) + assert np.isnan(arr[3]) - def test_no_mutation_of_source(self) -> None: - arr = np.array([10.0, 20.0, 30.0]) - idx1 = np.array([-1, 1]) - idx2 = np.array([0, 2]) - lookup_vals(arr, idx1) - result2 = lookup_vals(arr, idx2) - np.testing.assert_array_equal(result2, [10.0, 30.0]) - np.testing.assert_array_equal(arr, [10.0, 20.0, 30.0]) + def test_explicit_coordinate_names(self) -> None: + arr = _solution_from_names( + np.array([1.0, 2.0]), ["power[1]#5", "power[0]#3"], size=7 + ) + assert arr[3] == 2.0 + assert arr[5] == 1.0 + assert np.isnan(arr[4]) diff --git a/test/test_solvers.py b/test/test_solvers.py index c2522a02..c3142091 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -8,12 +8,12 @@ from pathlib import Path import numpy as np -import pandas as pd import pytest from test_io import model # noqa: F401 from linopy import GREATER_EQUAL, Model, solvers from linopy.constants import Result, Solution, Status +from linopy.constraints import CSRConstraint from linopy.solver_capabilities import ( SOLVER_REGISTRY, SolverFeature, @@ -130,7 +130,9 @@ def test_solver_state_compatibility_setters(simple_model: Model) -> None: def test_apply_result_explicit(simple_model: Model) -> None: x_labels = simple_model.variables["x"].labels.values y_labels = simple_model.variables["y"].labels.values - primal = pd.Series({int(x_labels): 1.5, int(y_labels): 2.0}, dtype=float) + primal = np.full(simple_model._xCounter, np.nan) + primal[int(x_labels)] = 1.5 + primal[int(y_labels)] = 2.0 solution = Solution(primal=primal, objective=5.5) result = Result( status=Status.from_termination_condition("optimal"), @@ -146,6 +148,33 @@ def test_apply_result_explicit(simple_model: Model) -> None: assert float(simple_model.variables["y"].solution) == 2.0 +def test_apply_result_with_csr_constraints_avoids_data_reconstruction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + m = Model(freeze_constraints=True) + x = m.add_variables(coords=[range(3)], name="x") + m.add_constraints(x >= 0, name="c") + con = m.constraints["c"] + assert isinstance(con, CSRConstraint) + + primal = np.arange(m._xCounter, dtype=float) + dual = np.arange(m._cCounter, dtype=float) + 10 + result = Result( + status=Status.from_termination_condition("optimal"), + solution=Solution(primal=primal, dual=dual, objective=1.0), + solver_name="mock", + ) + + def fail_data(self: CSRConstraint) -> None: + raise AssertionError("CSRConstraint.data was accessed") + + monkeypatch.setattr(CSRConstraint, "data", property(fail_data)) + m.apply_result(result) + + np.testing.assert_array_equal(m.variables["x"].solution.values, primal) + np.testing.assert_array_equal(m.constraints["c"].dual.values, dual) + + @pytest.mark.skipif( "gurobi" not in set(solvers.available_solvers), reason="Gurobi is not installed" ) From e91b3f7282ef9d07ed1ea3869b7b6b984c0bc439 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 15 May 2026 11:56:50 +0200 Subject: [PATCH 25/27] refactor: Solver.from_name factory + dataclass Solver is now a dataclass; subclasses inherit init. Construction routes through Solver.from_name(name, model, io_api=..., options=...) which builds at construction (direct API or LP/MPS file). solver.solve() returns a Result; model.apply_result(result) writes the solution back. Drops Model.prepare_solver/run_solver; to_* io helpers no longer warn. --- doc/release_notes.rst | 4 +- linopy/io.py | 62 +--- linopy/model.py | 163 +++------ linopy/solvers.py | 509 +++++++++++++++------------- test/test_constraint_label_index.py | 11 +- test/test_io.py | 28 +- test/test_solvers.py | 50 +-- test/test_sos_constraints.py | 12 +- 8 files changed, 388 insertions(+), 451 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index f2a089b7..9f777177 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -5,11 +5,11 @@ Upcoming Version ---------------- * Solver refactor: solver state now lives on a stateful ``Solver`` instance attached to ``Model.solver``. ``Model.solver_model`` and ``Model.solver_name`` become read-only properties delegating to ``model.solver`` (assigning anything other than ``None`` raises; setting ``None`` closes the solver). ``Model.solver_name`` may be ``None`` before a solve. The latter two properties may be deprecated in future versions. -* Two-step solve workflow *for advanced users*: ``Model.prepare_solver(solver_name, ...)`` builds the native solver model without solving, and ``Model.run_solver()`` runs it. ``Model.apply_result(result=None)`` exposes the solution-mapping step and defaults to the state on ``model.solver``. Only use these methods if you want to control optimization on the solver instance directly. +* Advanced solve workflow: construct via ``Solver.from_name(name, model, io_api=..., options=...)`` (or ``SolverClass.from_model(model, ...)``), then call ``solver.solve()`` to run and obtain a ``Result``, and ``model.apply_result(result)`` to write the solution back to the model. ``Solver`` is now a dataclass; subclasses no longer need ``__init__`` overrides. The previous two-step ``Model.prepare_solver`` / ``Model.run_solver`` API has been removed (it was added in the same upcoming release and not yet shipped). * Solver capabilities are declared as ``features: frozenset[SolverFeature]`` ClassVars on each ``Solver`` subclass; use ``Solver.supports(feature)``. ``SolverFeature`` is now exported from ``linopy`` (and from ``linopy.solvers``); ``linopy.solver_capabilities`` remains as a back-compat shim with a lazy ``SOLVER_REGISTRY`` mapping. * ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, dual bound, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``; when possible also populate the MIP ``dual_bound``. * ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray`` lookup arrays keyed by integer model labels (length = ``max_label + 1``, ``NaN`` for unfilled positions); previously ``pd.Series`` keyed by variable/constraint name. Each solver is responsible for emitting the label-indexed form — direct-API solvers via cached ``_vlabels``/``_clabels`` populated at ``to_solver_model`` time, file-based solvers via the shared ``_solution_from_names`` helper which parses linopy labels from solver-side names. Fixes solution mapping for file-based solvers that may iterate variables in a different order than linopy's build order or drop unused variables entirely. Internal — code that introspected these must use ``np.ndarray`` semantics. Solution mapping reads labels from a cached ``ConstraintLabelIndex`` on ``Model.constraints`` and no longer triggers a constraint-matrix rebuild. -* Per-solver translation helpers are unified under ``Solver._build_solver_model`` . The module-level ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` / ``to_cupdlpx`` (and their ``Model`` bindings) now emit ``DeprecationWarning`` with step-by-step migration to ``Solver.to_solver_model(model)`` or ``Model.prepare_solver(solver_name)``. +* Per-solver translation helpers are unified under ``Solver._build_solver_model``. The module-level ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` / ``to_cupdlpx`` (and their ``Model`` bindings) are kept as thin wrappers that route through ``Solver.from_model(model, io_api="direct")`` and return the native solver model. * Increase speed of direct solver communication (~10x) in conversion functions like `to_highspy` through faster matrix creation (see below), leading to significant overall speed-up when setting `io_api="direct"`. * Add ``CSRConstraint``, a memory-efficient immutable constraint representation backed by scipy CSR sparse matrices. Provides up to 90% memory savings for constraints with many terms and 30–120x faster matrix generation for direct solver APIs. - Add ``freeze_constraints`` parameter to ``Model`` for globally storing constraints in CSR format on ``add_constraints``. diff --git a/linopy/io.py b/linopy/io.py index 1649a659..36d7abb3 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -9,7 +9,6 @@ import logging import shutil import time -import warnings from collections.abc import Callable, Iterable from io import BufferedWriter from pathlib import Path @@ -641,17 +640,7 @@ def to_mosek( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> Any: - """Deprecated. Build the MOSEK task via ``Mosek`` or ``Model.prepare_solver``.""" - warnings.warn( - "to_mosek is deprecated and will be removed in a future version. " - "To obtain the MOSEK task, either:\n" - " 1) solver = linopy.solvers.Mosek(); " - "task = solver.to_solver_model(model); " - "or\n" - " 2) task = model.prepare_solver('mosek').", - DeprecationWarning, - stacklevel=2, - ) + """Build the MOSEK task for `m`.""" import mosek if task is None: @@ -670,23 +659,15 @@ def to_gurobipy( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> Any: - """Deprecated. Build the gurobipy model via ``Gurobi`` or ``Model.prepare_solver``.""" - warnings.warn( - "to_gurobipy is deprecated and will be removed in a future version. " - "To obtain the gurobipy.Model, either:\n" - " 1) solver = linopy.solvers.Gurobi(); " - "gm = solver.to_solver_model(model, env=env); " - "or\n" - " 2) gm = model.prepare_solver('gurobi', env=env).", - DeprecationWarning, - stacklevel=2, - ) - return solvers.Gurobi._build_solver_model( + """Build the gurobipy.Model for `m`.""" + solver = solvers.Gurobi.from_model( m, - env=env, + io_api="direct", explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, + env=env, ) + return solver.solver_model def to_highspy( @@ -694,37 +675,20 @@ def to_highspy( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> Highs: - """Deprecated. Build the highspy model via ``Highs`` or ``Model.prepare_solver``.""" - warnings.warn( - "to_highspy is deprecated and will be removed in a future version. " - "To obtain the highspy.Highs instance, either:\n" - " 1) solver = linopy.solvers.Highs(); " - "h = solver.to_solver_model(model); " - "or\n" - " 2) h = model.prepare_solver('highs').", - DeprecationWarning, - stacklevel=2, - ) - return solvers.Highs._build_solver_model( + """Build the highspy.Highs instance for `m`.""" + solver = solvers.Highs.from_model( m, + io_api="direct", explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, ) + return solver.solver_model def to_cupdlpx(m: Model) -> cupdlpxModel: - """Deprecated. Build the cupdlpx model via ``cuPDLPx`` or ``Model.prepare_solver``.""" - warnings.warn( - "to_cupdlpx is deprecated and will be removed in a future version. " - "To obtain the cupdlpx.Model, either:\n" - " 1) solver = linopy.solvers.cuPDLPx(); " - "cu = solver.to_solver_model(model); " - "or\n" - " 2) cu = model.prepare_solver('cupdlpx').", - DeprecationWarning, - stacklevel=2, - ) - return solvers.cuPDLPx._build_solver_model(m) + """Build the cupdlpx.Model for `m`.""" + solver = solvers.cuPDLPx.from_model(m, io_api="direct") + return solver.solver_model def to_block_files(m: Model, fn: Path) -> None: diff --git a/linopy/model.py b/linopy/model.py index d77641e2..665cad84 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -191,7 +191,7 @@ class Model: the optimization process. """ - solver: solvers.Solver | None + _solver: solvers.Solver | None _variables: Variables _constraints: Constraints _objective: Objective @@ -238,7 +238,7 @@ class Model: "_solver_dir", "_relaxed_registry", "_piecewise_formulations", - "solver", + "_solver", "__weakref__", ) @@ -308,7 +308,17 @@ def __init__( self._solver_dir: Path = Path( gettempdir() if solver_dir is None else solver_dir ) - self.solver: solvers.Solver | None = None + self._solver: solvers.Solver | None = None + + @property + def solver(self) -> solvers.Solver | None: + return self._solver + + @solver.setter + def solver(self, value: solvers.Solver | None) -> None: + if self._solver is not None and self._solver is not value: + self._solver.close() + self._solver = value @property def solver_model(self) -> Any: @@ -318,8 +328,6 @@ def solver_model(self) -> Any: def solver_model(self, value: Any) -> None: if value is not None: raise AttributeError("solver state is managed via model.solver") - if self.solver is not None: - self.solver.close() self.solver = None @property @@ -330,8 +338,6 @@ def solver_name(self) -> str | None: def solver_name(self, value: str | None) -> None: if value is not None: raise AttributeError("solver state is managed via model.solver") - if self.solver is not None: - self.solver.close() self.solver = None @property @@ -1727,129 +1733,52 @@ def solve( ) try: - if self.solver is not None: - self.solver.close() - solver = solver_class(**solver_options) - self.solver = solver - + self.solver = None # closes any previous solver if io_api == "direct": if set_names is None: set_names = self.set_names_in_solver_io - solver.solve_problem_from_model( - model=self, - solution_fn=to_path(solution_fn), - log_fn=to_path(log_fn), - warmstart_fn=to_path(warmstart_fn), - basis_fn=to_path(basis_fn), - env=env, - explicit_coordinate_names=explicit_coordinate_names, - set_names=set_names, - ) + build_kwargs: dict[str, Any] = { + "explicit_coordinate_names": explicit_coordinate_names, + "set_names": set_names, + "log_fn": to_path(log_fn), + } + if env is not None: + build_kwargs["env"] = env else: - if ( - not solver_class.supports(SolverFeature.LP_FILE_NAMES) - and explicit_coordinate_names - ): - logger.warning( - f"{solver_name} does not support writing names to lp files, disabling it." - ) - explicit_coordinate_names = False - problem_fn = self.to_file( - to_path(problem_fn), - io_api=io_api, - explicit_coordinate_names=explicit_coordinate_names, - slice_size=slice_size, - progress=progress, - ) - solver._cache_model_sizes(self) - solver.solve_problem_from_file( - problem_fn=to_path(problem_fn), - solution_fn=to_path(solution_fn), - log_fn=to_path(log_fn), - warmstart_fn=to_path(warmstart_fn), - basis_fn=to_path(basis_fn), - env=env, - ) - + build_kwargs = { + "explicit_coordinate_names": explicit_coordinate_names, + "slice_size": slice_size, + "progress": progress, + "problem_fn": to_path(problem_fn), + } + self.solver = solver = solvers.Solver.from_name( + solver_name, + model=self, + io_api=io_api, + options=solver_options, + **build_kwargs, + ) + if io_api != "direct": + problem_fn = solver._problem_fn + result = solver.solve( + solution_fn=to_path(solution_fn), + log_fn=to_path(log_fn), + warmstart_fn=to_path(warmstart_fn), + basis_fn=to_path(basis_fn), + env=env, + ) finally: for fn in (problem_fn, solution_fn): if fn is not None and (os.path.exists(fn) and not keep_files): os.remove(fn) try: - return self.apply_result() + return self.apply_result(result) finally: if sos_reform_result is not None: undo_sos_reformulation(self, sos_reform_result) - def prepare_solver( - self, - solver_name: str, - explicit_coordinate_names: bool = False, - set_names: bool | None = None, - env: Any = None, - log_fn: Path | None = None, - **solver_options: Any, - ) -> Any: - """ - Build the solver-native model for `solver_name` without solving. - - Instantiates the solver, attaches it as `self.solver`, and returns - the native model object (e.g. `gurobipy.Model`). Pair with `run_solver()` - for a two-step build-then-solve workflow. - """ - solver_class = solvers._solver_class_for(solver_name) - if solver_class is None: - raise ValueError(f"Unknown solver name: {solver_name}") - if not solver_class.supports(SolverFeature.DIRECT_API): - raise NotImplementedError( - f"Solver {solver_name} does not support direct API model export." - ) - if set_names is None: - set_names = self.set_names_in_solver_io - if self.solver is not None: - self.solver.close() - solver = solver_class(**solver_options) - self.solver = solver - try: - return solver.to_solver_model( - self, - explicit_coordinate_names=explicit_coordinate_names, - set_names=set_names, - env=env, - log_fn=to_path(log_fn), - ) - except Exception: - solver.close() - self.solver = None - raise - - def run_solver(self) -> tuple[str, str]: - """ - Solve the previously prepared solver model and apply the result. - - Requires a prior `prepare_solver(...)`. Returns the - `(status, termination_condition)` tuple from `apply_result`. - """ - if self.solver is None: - raise RuntimeError("call prepare_solver() first") - self.solver.run() - return self.apply_result() - - def apply_result(self, result: Result | None = None) -> tuple[str, str]: - if result is None: - if self.solver is None or self.solver.status is None: - raise RuntimeError( - "No solver state available; call solve() first or pass a Result." - ) - result = Result( - status=self.solver.status, - solution=self.solver.solution, - solver_model=self.solver.solver_model, - solver_name=self.solver.solver_name.value, - report=self.solver.report, - ) - + def apply_result(self, result: Result) -> tuple[str, str]: result.info() if result.solution is not None: @@ -1892,8 +1821,6 @@ def _mock_solve( solver_name = "mock" logger.info(f" Solve problem using {solver_name.title()} solver") - if self.solver is not None: - self.solver.close() self.solver = None # reset result self.reset_solution() diff --git a/linopy/solvers.py b/linopy/solvers.py index 33e210a8..42e7d259 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -15,9 +15,10 @@ import sys import threading import warnings -from abc import ABC, abstractmethod +from abc import ABC from collections import namedtuple from collections.abc import Callable, Generator +from dataclasses import dataclass, field from enum import Enum, auto from importlib.metadata import PackageNotFoundError from importlib.metadata import version as package_version @@ -383,17 +384,56 @@ def maybe_adjust_objective_sign( return solution +@dataclass class Solver(ABC, Generic[EnvType]): """ Abstract base class for solving a given linear problem. - All relevant functions are passed on to the specific solver subclasses. - Subclasses must implement the `solve_problem_from_model()` and - `solve_problem_from_file()` methods. + Subclasses provide ``_build_direct`` / ``_run_direct`` (when supporting the + direct API) and ``_run_file`` (when supporting LP/MPS files). Construction + goes via :meth:`Solver.from_name` or :meth:`Solver.from_model`. """ + model: Model | None = None + io_api: str | None = None + options: dict[str, Any] = field(default_factory=dict) + + # Runtime state — never set via constructor. + status: Status | None = field(init=False, default=None, repr=False) + solution: Solution | None = field(init=False, default=None, repr=False) + report: SolverReport | None = field(init=False, default=None, repr=False) + solver_model: Any = field(init=False, default=None, repr=False) + sense: str | None = field(init=False, default=None, repr=False) + env: Any = field(init=False, default=None, repr=False) + _env_stack: contextlib.ExitStack | None = field( + init=False, default=None, repr=False + ) + _vlabels: np.ndarray | None = field(init=False, default=None, repr=False) + _clabels: np.ndarray | None = field(init=False, default=None, repr=False) + _n_vars: int = field(init=False, default=0, repr=False) + _n_cons: int = field(init=False, default=0, repr=False) + _problem_fn: Path | None = field(init=False, default=None, repr=False) + display_name: ClassVar[str] = "" features: ClassVar[frozenset[SolverFeature]] = frozenset() + accepted_io_apis: ClassVar[frozenset[str]] = frozenset() + + def __post_init__(self) -> None: + if type(self) is Solver: + raise TypeError( + "Solver is abstract; instantiate a concrete subclass instead." + ) + if self.solver_name.value not in available_solvers: + msg = ( + f"Solver package for '{self.solver_name.value}' is not installed. " + "Please install first to initialize solver instance." + ) + raise ImportError(msg) + + @property + def solver_options(self) -> dict[str, Any]: + """Back-compat alias for ``self.options``.""" + return self.options @classmethod def runtime_features(cls) -> frozenset[SolverFeature]: @@ -413,32 +453,179 @@ def supports(cls, feature: SolverFeature) -> bool: """Check if this solver supports a given feature.""" return feature in cls.features or feature in cls.runtime_features() - def __init__( + @staticmethod + def from_name( + name: str, + model: Model, + io_api: str | None = None, + options: dict[str, Any] | None = None, + **build_kwargs: Any, + ) -> Solver: + """Construct and build the solver subclass registered as ``name``.""" + cls = _solver_class_for(name) + if cls is None: + raise ValueError(f"unknown solver: {name}") + return cls.from_model( + model, io_api=io_api, options=options or {}, **build_kwargs + ) + + @classmethod + def from_model( + cls, + model: Model, + io_api: str | None = None, + options: dict[str, Any] | None = None, + **build_kwargs: Any, + ) -> Solver: + """Instantiate and build the solver against ``model``.""" + instance = cls(model=model, io_api=io_api, options=options or {}) + instance._build(**build_kwargs) + return instance + + def _build(self, **build_kwargs: Any) -> None: + """Dispatch to direct or file build based on ``io_api``.""" + if self.model is None: + raise RuntimeError("Solver has no model attached; cannot build.") + if self.io_api == "direct": + self._build_direct(**build_kwargs) + else: + self._build_file(**build_kwargs) + + def _build_direct(self, **build_kwargs: Any) -> None: + """Build the native solver model from ``self.model``. Override per-solver.""" + if not self.supports(SolverFeature.DIRECT_API): + raise NotImplementedError( + f"Solver {self.solver_name.value} does not support direct API model export." + ) + # Default: delegate to legacy to_solver_model on the subclass. + self.to_solver_model(self.model, **build_kwargs) + + def _build_file(self, **build_kwargs: Any) -> None: + """Write the LP/MPS file for ``self.model`` and cache its path.""" + model = self.model + assert model is not None + io_api = self.io_api + if io_api is not None and io_api not in FILE_IO_APIS: + raise ValueError( + f"Keyword argument `io_api` has to be one of {IO_APIS} or None" + ) + explicit_coordinate_names = build_kwargs.pop( + "explicit_coordinate_names", False + ) + slice_size = build_kwargs.pop("slice_size", 2_000_000) + progress = build_kwargs.pop("progress", None) + problem_fn = build_kwargs.pop("problem_fn", None) + if problem_fn is None: + problem_fn = model.get_problem_file(io_api=io_api) + if ( + not self.supports(SolverFeature.LP_FILE_NAMES) + and explicit_coordinate_names + ): + logger.warning( + f"{self.solver_name.value} does not support writing names to " + "lp files, disabling it." + ) + explicit_coordinate_names = False + problem_fn = model.to_file( + Path(problem_fn) if not isinstance(problem_fn, Path) else problem_fn, + io_api=io_api, + explicit_coordinate_names=explicit_coordinate_names, + slice_size=slice_size, + progress=progress, + ) + self._problem_fn = problem_fn + if self.io_api is None: + self.io_api = read_io_api_from_problem_file(problem_fn) + self._cache_model_sizes(model) + + def solve(self, **run_kwargs: Any) -> Result: + """Run the prepared solver and return a :class:`Result`.""" + if self.io_api == "direct" or self.solver_model is not None: + return self._run_direct(**run_kwargs) + if self._problem_fn is not None: + return self._run_file(**run_kwargs) + raise RuntimeError( + "Solver has not been built; call Solver.from_name(...) or _build() first." + ) + + def _run_direct(self, **run_kwargs: Any) -> Result: + """Run the pre-built native solver model. Override per-solver.""" + raise NotImplementedError( + f"Direct API run not implemented for {self.solver_name.value}" + ) + + def _run_file(self, **run_kwargs: Any) -> Result: + """Invoke the solver binary on ``self._problem_fn``. Override per-solver.""" + if self._problem_fn is None: + raise RuntimeError("No problem file built; call _build_file() first.") + return self.solve_problem_from_file(self._problem_fn, **run_kwargs) + + def solve_problem( self, - **solver_options: Any, - ) -> None: - self.options: dict[str, Any] = solver_options - self.solver_options: dict[str, Any] = solver_options - self.status: Status | None = None - self.solution: Solution | None = None - self.report: SolverReport | None = None - self.solver_model: Any = None - self.io_api: str | None = None - self.sense: str | None = None - self.env: Any = None - self._env_stack: contextlib.ExitStack | None = None - # cached at to_solver_model time so direct-API solvers can build - # label-indexed Solution.primal/dual without re-fetching the model. - self._vlabels: np.ndarray | None = None - self._clabels: np.ndarray | None = None - # Total label counts (model._xCounter / _cCounter), sized so that - # masked label slots are preserved in the dense Solution arrays. - self._n_vars: int = 0 - self._n_cons: int = 0 + model: Model | None = None, + problem_fn: Path | None = None, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: EnvType | None = None, + explicit_coordinate_names: bool = False, + ) -> Result: + """Legacy wrapper. Dispatches to model- or file-based entry point.""" + if problem_fn is not None and model is not None: + raise ValueError( + "Both problem file and model are given. Please specify only one." + ) + if model is not None: + return self.solve_problem_from_model( + model=model, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + env=env, + explicit_coordinate_names=explicit_coordinate_names, + ) + if problem_fn is not None: + return self.solve_problem_from_file( + problem_fn=problem_fn, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + env=env, + ) + raise ValueError("No problem file or model specified.") - if self.solver_name.value not in available_solvers: - msg = f"Solver package for '{self.solver_name.value}' is not installed. Please install first to initialize solver instance." - raise ImportError(msg) + def solve_problem_from_model( + self, + model: Model, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: EnvType | None = None, + explicit_coordinate_names: bool = False, + set_names: bool = True, + ) -> Result: + """Legacy direct-API entry point. Default raises NotImplementedError.""" + raise NotImplementedError( + f"Direct API not implemented for {self.solver_name.value}" + ) + + def solve_problem_from_file( + self, + problem_fn: Path, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: EnvType | None = None, + ) -> Result: + """Legacy file-based entry point. Default raises NotImplementedError.""" + raise NotImplementedError( + f"File-based API not implemented for {self.solver_name.value}" + ) def to_solver_model(self, model: Model, **kwargs: Any) -> Any: raise NotImplementedError @@ -458,16 +645,6 @@ def _cache_model_sizes(self, model: Model) -> None: def update_solver_model(self, model: Model, **kwargs: Any) -> None: raise NotImplementedError - def run(self) -> Result: - if self.solver_model is None: - raise RuntimeError("call to_solver_model first") - if self.sense is None: - raise RuntimeError("sense not set; call to_solver_model first") - return self._run() - - def _run(self) -> Result: - raise NotImplementedError - def close(self) -> None: if self._env_stack is not None: self._env_stack.close() @@ -533,115 +710,6 @@ def safe_get_solution( logger.error(f"Failed to parse solution: {e}") return Solution() - @abstractmethod - def solve_problem_from_model( - self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: EnvType | None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - """ - Solve a linear problem directly from a linopy model. - - Subclasses that support the direct API translate the model into the - solver's native representation and run it. Subclasses without direct - API support must still implement this method and raise NotImplementedError. - - Parameters - ---------- - model : linopy.Model - Linopy model for the problem. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : EnvType, optional - Solver-specific environment object (or None when not applicable). - explicit_coordinate_names : bool, optional - Transfer variable and constraint coordinate names to the solver - (default: False). - set_names : bool, optional - Whether to set variable and constraint names (default: True). - Setting to False can significantly speed up model export. - - Returns - ------- - Result - """ - pass - - @abstractmethod - def solve_problem_from_file( - self, - problem_fn: Path, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: EnvType | None = None, - ) -> Result: - """ - Abstract method to solve a linear problem from a problem file. - - Needs to be implemented in the specific solver subclass. Even if the solver - does not support solving from a file, this method should be implemented and - raise a NotImplementedError. - """ - pass - - def solve_problem( - self, - model: Model | None = None, - problem_fn: Path | None = None, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: EnvType | None = None, - explicit_coordinate_names: bool = False, - ) -> Result: - """ - Solve a linear problem either from a model or a problem file. - - Wraps around `self.solve_problem_from_model()` and - `self.solve_problem_from_file()` and calls the appropriate method - based on the input arguments (`model` or `problem_fn`). - """ - if problem_fn is not None and model is not None: - msg = "Both problem file and model are given. Please specify only one." - raise ValueError(msg) - elif model is not None: - return self.solve_problem_from_model( - model=model, - solution_fn=solution_fn, - log_fn=log_fn, - warmstart_fn=warmstart_fn, - basis_fn=basis_fn, - env=env, - explicit_coordinate_names=explicit_coordinate_names, - ) - elif problem_fn is not None: - return self.solve_problem_from_file( - problem_fn=problem_fn, - solution_fn=solution_fn, - log_fn=log_fn, - warmstart_fn=warmstart_fn, - basis_fn=basis_fn, - env=env, - ) - else: - msg = "No problem file or model specified." - raise ValueError(msg) - @property def solver_name(self) -> SolverName: return SolverName[self.__class__.__name__] @@ -665,12 +733,6 @@ class CBC(Solver[None]): } ) - def __init__( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def solve_problem_from_model( self, model: Model, @@ -875,12 +937,6 @@ class GLPK(Solver[None]): } ) - def __init( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def solve_problem_from_model( self, model: Model, @@ -1079,12 +1135,6 @@ class Highs(Solver[None]): } ) - def __init__( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def to_solver_model( self, model: Model, @@ -1213,8 +1263,23 @@ def solve_problem_from_model( sense=model.sense, ) - def _run(self) -> Result: - return self._solve(self.solver_model, io_api=self.io_api, sense=self.sense) + def _run_direct( + self, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: Any = None, + **kw: Any, + ) -> Result: + return self._solve( + self.solver_model, + solution_fn=solution_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + io_api=self.io_api, + sense=self.sense, + ) def solve_problem_from_file( self, @@ -1427,12 +1492,6 @@ class Gurobi(Solver["gurobipy.Env | dict[str, Any] | None"]): } ) - def __init__( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def _resolve_env(self, env: gurobipy.Env | dict[str, Any] | None) -> gurobipy.Env: self.close() self._env_stack = contextlib.ExitStack() @@ -1557,13 +1616,21 @@ def solve_problem_from_model( sense=model.sense, ) - def _run(self) -> Result: + def _run_direct( + self, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: Any = None, + **kw: Any, + ) -> Result: return self._solve( self.solver_model, - solution_fn=None, - log_fn=None, - warmstart_fn=None, - basis_fn=None, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, io_api=self.io_api, sense=self.sense, ) @@ -1784,12 +1851,6 @@ class Cplex(Solver[None]): } ) - def __init__( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def solve_problem_from_model( self, model: Model, @@ -1953,12 +2014,6 @@ class SCIP(Solver[None]): } ) - def __init__( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def solve_problem_from_model( self, model: Model, @@ -2131,12 +2186,6 @@ def runtime_features(cls) -> frozenset[SolverFeature]: return frozenset({SolverFeature.GPU_ACCELERATION}) return frozenset() - def __init__( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def solve_problem_from_model( self, model: Model, @@ -2319,12 +2368,6 @@ class Knitro(Solver[None]): } ) - def __init__( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def solve_problem_from_model( self, model: Model, @@ -2590,12 +2633,6 @@ class Mosek(Solver[None]): } ) - def __init__( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def solve_problem_from_model( self, model: Model, @@ -2630,13 +2667,21 @@ def solve_problem_from_model( sense=model.sense, ) - def _run(self) -> Result: + def _run_direct( + self, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: Any = None, + **kw: Any, + ) -> Result: return self._solve( self.solver_model, - solution_fn=None, - log_fn=None, - warmstart_fn=None, - basis_fn=None, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, io_api=self.io_api, sense=self.sense, ) @@ -3056,12 +3101,6 @@ class COPT(Solver[None]): } ) - def __init( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def solve_problem_from_model( self, model: Model, @@ -3220,12 +3259,6 @@ class MindOpt(Solver[None]): } ) - def __init( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def solve_problem_from_model( self, model: Model, @@ -3366,11 +3399,7 @@ class PIPS(Solver[None]): Solver subclass for the PIPS solver. """ - def __init__( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) + def __post_init__(self) -> None: msg = "The PIPS solver interface is not yet implemented." raise NotImplementedError(msg) @@ -3404,12 +3433,6 @@ class cuPDLPx(Solver[None]): } ) - def __init__( - self, - **solver_options: Any, - ) -> None: - super().__init__(**solver_options) - def solve_problem_from_file( self, problem_fn: Path, @@ -3545,9 +3568,21 @@ def _build_solver_model(model: Model) -> cupdlpx.Model: return cu_model - def _run(self) -> Result: + def _run_direct( + self, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: Any = None, + **kw: Any, + ) -> Result: return self._solve( self.solver_model, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, io_api=self.io_api, sense=self.sense, ) diff --git a/test/test_constraint_label_index.py b/test/test_constraint_label_index.py index 554882af..bcb506db 100644 --- a/test/test_constraint_label_index.py +++ b/test/test_constraint_label_index.py @@ -3,6 +3,7 @@ import pytest import linopy +import linopy.constants from linopy.constraints import Constraint @@ -42,7 +43,15 @@ def counting(self, label_index): # type: ignore[no-untyped-def] assert model_with_mask.status == "ok" # one build for solver input is fine; the post-solve mapping must not add more n_after_solve = calls["n"] - model_with_mask.apply_result() + solver = model_with_mask.solver + result = linopy.constants.Result( + status=solver.status, + solution=solver.solution, + solver_model=solver.solver_model, + solver_name=solver.solver_name.value, + report=solver.report, + ) + model_with_mask.apply_result(result) assert calls["n"] == n_after_solve diff --git a/test/test_io.py b/test/test_io.py index 5e57de4b..b049c0dc 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -274,15 +274,14 @@ def test_to_file_invalid(model: Model, tmp_path: Path) -> None: @pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") def test_to_gurobipy(model: Model) -> None: - with pytest.warns(DeprecationWarning, match="to_gurobipy is deprecated"): - model.to_gurobipy() + gm = model.to_gurobipy() + assert gm.NumVars > 0 @pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") def test_to_gurobipy_no_names(model: Model) -> None: - with pytest.warns(DeprecationWarning, match="to_gurobipy is deprecated"): - m_with = model.to_gurobipy(set_names=True) - m_without = model.to_gurobipy(set_names=False) + m_with = model.to_gurobipy(set_names=True) + m_without = model.to_gurobipy(set_names=False) names_with = [v.VarName for v in m_with.getVars()] names_without = [v.VarName for v in m_without.getVars()] assert names_with != names_without @@ -290,29 +289,28 @@ def test_to_gurobipy_no_names(model: Model) -> None: @pytest.mark.skipif("highs" not in available_solvers, reason="Highspy not installed") def test_to_highspy(model: Model) -> None: - with pytest.warns(DeprecationWarning, match="to_highspy is deprecated"): - model.to_highspy() + h = model.to_highspy() + assert h.getLp().num_col_ > 0 @pytest.mark.skipif("highs" not in available_solvers, reason="Highspy not installed") def test_to_highspy_no_names(model: Model) -> None: - with pytest.warns(DeprecationWarning, match="to_highspy is deprecated"): - h = model.to_highspy(set_names=False) + h = model.to_highspy(set_names=False) lp = h.getLp() assert len(lp.col_names_) == 0 assert len(lp.row_names_) == 0 @pytest.mark.skipif("mosek" not in available_solvers, reason="Mosek not installed") -def test_to_mosek_deprecation_warning(model: Model) -> None: - with pytest.warns(DeprecationWarning, match="to_mosek is deprecated"): - model.to_mosek() +def test_to_mosek(model: Model) -> None: + task = model.to_mosek() + assert task.getnumvar() > 0 @pytest.mark.skipif("cupdlpx" not in available_solvers, reason="cuPDLPx not installed") -def test_to_cupdlpx_deprecation_warning(model: Model) -> None: - with pytest.warns(DeprecationWarning, match="to_cupdlpx is deprecated"): - model.to_cupdlpx() +def test_to_cupdlpx(model: Model) -> None: + cu = model.to_cupdlpx() + assert cu is not None def test_model_set_names_in_solver_io_default() -> None: diff --git a/test/test_solvers.py b/test/test_solvers.py index c3142091..68e31fa4 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -49,19 +49,19 @@ def test_solver_instance_attached_after_solve(simple_model: Model, solver: str) def test_result_carries_solver_name(simple_model: Model, solver: str) -> None: if not solver_supports(solver, SolverFeature.DIRECT_API): pytest.skip("Solver does not support direct API.") - solver_enum = solvers.SolverName(solver.lower()) - solver_class = getattr(solvers, solver_enum.name) - instance = solver_class() - result = instance.solve_problem(model=simple_model) + instance = solvers.Solver.from_name(solver, simple_model, io_api="direct") + result = instance.solve() assert result.solver_name == solver @pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) -def test_prepare_solver_then_run(simple_model: Model, solver: str) -> None: +def test_from_name_then_solve(simple_model: Model, solver: str) -> None: if not solver_supports(solver, SolverFeature.DIRECT_API): pytest.skip("Solver does not support direct API.") - simple_model.prepare_solver(solver) - simple_model.run_solver() + built = solvers.Solver.from_name(solver, simple_model, io_api="direct") + assert built.solver_model is not None + result = built.solve() + simple_model.apply_result(result) reference = Model(chunk=None) rx = reference.add_variables(name="x") @@ -76,11 +76,14 @@ def test_prepare_solver_then_run(simple_model: Model, solver: str) -> None: @pytest.mark.parametrize("solver", sorted(set(solvers.available_solvers))) -def test_prepare_solver_set_names_false_run(simple_model: Model, solver: str) -> None: +def test_from_name_set_names_false(simple_model: Model, solver: str) -> None: if not solver_supports(solver, SolverFeature.DIRECT_API): pytest.skip("Solver does not support direct API.") - simple_model.prepare_solver(solver, set_names=False) - status, condition = simple_model.run_solver() + built = solvers.Solver.from_name( + solver, simple_model, io_api="direct", set_names=False + ) + result = built.solve() + status, condition = simple_model.apply_result(result) assert status == "ok" assert condition == "optimal" @@ -89,18 +92,19 @@ def test_prepare_solver_set_names_false_run(simple_model: Model, solver: str) -> assert float(simple_model.variables["y"].solution) == pytest.approx(1.7) -def test_prepare_solver_unknown_name_raises(simple_model: Model) -> None: - with pytest.raises(ValueError, match="Unknown solver name"): - simple_model.prepare_solver("not_a_real_solver") +def test_from_name_unknown_solver_raises(simple_model: Model) -> None: + with pytest.raises(ValueError, match="unknown solver"): + solvers.Solver.from_name("not_a_real_solver", simple_model, io_api="direct") @pytest.mark.skipif( "highs" not in set(solvers.available_solvers), reason="HiGHS is not installed" ) -def test_highs_prepare_solver_applies_solver_options(simple_model: Model) -> None: - highs_model = simple_model.prepare_solver("highs", time_limit=123) - - option_status, time_limit = highs_model.getOptionValue("time_limit") +def test_from_name_applies_solver_options(simple_model: Model) -> None: + built = solvers.Solver.from_name( + "highs", simple_model, io_api="direct", options={"time_limit": 123} + ) + option_status, time_limit = built.solver_model.getOptionValue("time_limit") assert str(option_status) == "HighsStatus.kOk" assert time_limit == 123 @@ -109,13 +113,17 @@ def test_highs_prepare_solver_applies_solver_options(simple_model: Model) -> Non "highs" not in set(solvers.available_solvers), reason="HiGHS is not installed" ) def test_solver_state_compatibility_setters(simple_model: Model) -> None: - simple_model.prepare_solver("highs") + simple_model.solver = solvers.Solver.from_name( + "highs", simple_model, io_api="direct" + ) simple_model.solver_model = None assert simple_model.solver is None assert simple_model.solver_model is None assert simple_model.solver_name is None - simple_model.prepare_solver("highs") + simple_model.solver = solvers.Solver.from_name( + "highs", simple_model, io_api="direct" + ) simple_model.solver_name = None assert simple_model.solver is None assert simple_model.solver_model is None @@ -306,7 +314,7 @@ def test_knitro_solver_for_lp(tmp_path: Path) -> None: ) def test_knitro_solver_with_options(tmp_path: Path) -> None: """Test Knitro solver with custom options.""" - knitro = solvers.Knitro(maxit=100, feastol=1e-6) + knitro = solvers.Knitro(options={"maxit": 100, "feastol": 1e-6}) mps_file = tmp_path / "problem.mps" mps_file.write_text(free_mps_problem) @@ -336,7 +344,7 @@ def test_knitro_solver_with_model_raises_error(model: Model) -> None: # noqa: F ) def test_knitro_solver_no_log(tmp_path: Path) -> None: """Test Knitro solver without log file.""" - knitro = solvers.Knitro(outlev=0) + knitro = solvers.Knitro(options={"outlev": 0}) mps_file = tmp_path / "problem.mps" mps_file.write_text(free_mps_problem) diff --git a/test/test_sos_constraints.py b/test/test_sos_constraints.py index 67447228..5d94162e 100644 --- a/test/test_sos_constraints.py +++ b/test/test_sos_constraints.py @@ -70,8 +70,7 @@ def test_to_gurobipy_emits_sos_constraints() -> None: m.add_sos_constraints(var, sos_type=1, sos_dim="seg") try: - with pytest.warns(DeprecationWarning, match="to_gurobipy is deprecated"): - model = m.to_gurobipy() + model = m.to_gurobipy() except gurobipy.GurobiError as exc: # pragma: no cover - depends on license setup pytest.skip(f"Gurobi environment unavailable: {exc}") @@ -159,11 +158,8 @@ def test_to_highspy_raises_not_implemented() -> None: build = m.add_variables(coords=[locations], name="build", binary=True) m.add_sos_constraints(build, sos_type=1, sos_dim="locations") - with ( - pytest.warns(DeprecationWarning, match="to_highspy is deprecated"), - pytest.raises( - NotImplementedError, - match="SOS constraints are not supported by the HiGHS direct API", - ), + with pytest.raises( + NotImplementedError, + match="SOS constraints are not supported by the HiGHS direct API", ): m.to_highspy() From fe188600a111f6975ecbde38d36472a95b1e819d Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 15 May 2026 12:20:53 +0200 Subject: [PATCH 26/27] refactor: deprecate solve_problem_from_*, fold to_solver_model into _build_direct --- doc/release_notes.rst | 2 +- linopy/solvers.py | 717 ++++++++---------------------------------- 2 files changed, 126 insertions(+), 593 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 9f777177..d399afe3 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -8,7 +8,7 @@ Upcoming Version * Advanced solve workflow: construct via ``Solver.from_name(name, model, io_api=..., options=...)`` (or ``SolverClass.from_model(model, ...)``), then call ``solver.solve()`` to run and obtain a ``Result``, and ``model.apply_result(result)`` to write the solution back to the model. ``Solver`` is now a dataclass; subclasses no longer need ``__init__`` overrides. The previous two-step ``Model.prepare_solver`` / ``Model.run_solver`` API has been removed (it was added in the same upcoming release and not yet shipped). * Solver capabilities are declared as ``features: frozenset[SolverFeature]`` ClassVars on each ``Solver`` subclass; use ``Solver.supports(feature)``. ``SolverFeature`` is now exported from ``linopy`` (and from ``linopy.solvers``); ``linopy.solver_capabilities`` remains as a back-compat shim with a lazy ``SOLVER_REGISTRY`` mapping. * ``Result`` gains ``solver_name`` and ``report: SolverReport | None`` (runtime, MIP gap, dual bound, iteration counts) and prints them in ``__repr__``. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populate ``report``; when possible also populate the MIP ``dual_bound``. -* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray`` lookup arrays keyed by integer model labels (length = ``max_label + 1``, ``NaN`` for unfilled positions); previously ``pd.Series`` keyed by variable/constraint name. Each solver is responsible for emitting the label-indexed form — direct-API solvers via cached ``_vlabels``/``_clabels`` populated at ``to_solver_model`` time, file-based solvers via the shared ``_solution_from_names`` helper which parses linopy labels from solver-side names. Fixes solution mapping for file-based solvers that may iterate variables in a different order than linopy's build order or drop unused variables entirely. Internal — code that introspected these must use ``np.ndarray`` semantics. Solution mapping reads labels from a cached ``ConstraintLabelIndex`` on ``Model.constraints`` and no longer triggers a constraint-matrix rebuild. +* ``Solution.primal`` and ``Solution.dual`` are now ``np.ndarray`` lookup arrays keyed by integer model labels (length = ``max_label + 1``, ``NaN`` for unfilled positions); previously ``pd.Series`` keyed by variable/constraint name. Each solver is responsible for emitting the label-indexed form — direct-API solvers via cached ``_vlabels``/``_clabels`` populated at ``_build_direct`` time, file-based solvers via the shared ``_solution_from_names`` helper which parses linopy labels from solver-side names. Fixes solution mapping for file-based solvers that may iterate variables in a different order than linopy's build order or drop unused variables entirely. Internal — code that introspected these must use ``np.ndarray`` semantics. Solution mapping reads labels from a cached ``ConstraintLabelIndex`` on ``Model.constraints`` and no longer triggers a constraint-matrix rebuild. * Per-solver translation helpers are unified under ``Solver._build_solver_model``. The module-level ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` / ``to_cupdlpx`` (and their ``Model`` bindings) are kept as thin wrappers that route through ``Solver.from_model(model, io_api="direct")`` and return the native solver model. * Increase speed of direct solver communication (~10x) in conversion functions like `to_highspy` through faster matrix creation (see below), leading to significant overall speed-up when setting `io_api="direct"`. * Add ``CSRConstraint``, a memory-efficient immutable constraint representation backed by scipy CSR sparse matrices. Provides up to 90% memory savings for constraints with many terms and 30–120x faster matrix generation for direct solver APIs. diff --git a/linopy/solvers.py b/linopy/solvers.py index 42e7d259..1fb22e09 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -493,12 +493,9 @@ def _build(self, **build_kwargs: Any) -> None: def _build_direct(self, **build_kwargs: Any) -> None: """Build the native solver model from ``self.model``. Override per-solver.""" - if not self.supports(SolverFeature.DIRECT_API): - raise NotImplementedError( - f"Solver {self.solver_name.value} does not support direct API model export." - ) - # Default: delegate to legacy to_solver_model on the subclass. - self.to_solver_model(self.model, **build_kwargs) + raise NotImplementedError( + f"Solver {self.solver_name.value} does not support direct API model export." + ) def _build_file(self, **build_kwargs: Any) -> None: """Write the LP/MPS file for ``self.model`` and cache its path.""" @@ -551,14 +548,14 @@ def solve(self, **run_kwargs: Any) -> Result: def _run_direct(self, **run_kwargs: Any) -> Result: """Run the pre-built native solver model. Override per-solver.""" raise NotImplementedError( - f"Direct API run not implemented for {self.solver_name.value}" + f"Direct API not implemented for {self.solver_name.value}" ) def _run_file(self, **run_kwargs: Any) -> Result: """Invoke the solver binary on ``self._problem_fn``. Override per-solver.""" - if self._problem_fn is None: - raise RuntimeError("No problem file built; call _build_file() first.") - return self.solve_problem_from_file(self._problem_fn, **run_kwargs) + raise NotImplementedError( + f"File-based API not implemented for {self.solver_name.value}" + ) def solve_problem( self, @@ -571,7 +568,14 @@ def solve_problem( env: EnvType | None = None, explicit_coordinate_names: bool = False, ) -> Result: - """Legacy wrapper. Dispatches to model- or file-based entry point.""" + """Deprecated. Use ``Solver.from_name(...).solve(...)`` or ``Model.solve(...)``.""" + warnings.warn( + "Solver.solve_problem is deprecated and will be removed in a future " + "release. Use Solver.from_name(name, model, ...).solve(...) or " + "Model.solve(...) instead.", + DeprecationWarning, + stacklevel=2, + ) if problem_fn is not None and model is not None: raise ValueError( "Both problem file and model are given. Please specify only one." @@ -608,9 +612,33 @@ def solve_problem_from_model( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> Result: - """Legacy direct-API entry point. Default raises NotImplementedError.""" - raise NotImplementedError( - f"Direct API not implemented for {self.solver_name.value}" + """Deprecated shim that builds via ``_build_direct`` and runs via ``_run_direct``.""" + warnings.warn( + "Solver.solve_problem_from_model is deprecated and will be removed in a " + "future release. Use Solver.from_name(name, model, io_api='direct', ...)" + ".solve(...) instead.", + DeprecationWarning, + stacklevel=2, + ) + if not self.supports(SolverFeature.DIRECT_API): + raise NotImplementedError( + f"Direct API not implemented for {self.solver_name.value}" + ) + self.model = model + build_kwargs: dict[str, Any] = { + "explicit_coordinate_names": explicit_coordinate_names, + "set_names": set_names, + "log_fn": log_fn, + } + if env is not None: + build_kwargs["env"] = env + self._build_direct(**build_kwargs) + return self._run_direct( + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + env=env, ) def solve_problem_from_file( @@ -622,13 +650,24 @@ def solve_problem_from_file( basis_fn: Path | None = None, env: EnvType | None = None, ) -> Result: - """Legacy file-based entry point. Default raises NotImplementedError.""" - raise NotImplementedError( - f"File-based API not implemented for {self.solver_name.value}" + """Deprecated shim that caches ``problem_fn`` and runs via ``_run_file``.""" + warnings.warn( + "Solver.solve_problem_from_file is deprecated and will be removed in a " + "future release. Use Solver.from_name(name, model, problem_fn=..., ...)" + ".solve(...) instead.", + DeprecationWarning, + stacklevel=2, + ) + problem_fn = Path(problem_fn) if not isinstance(problem_fn, Path) else problem_fn + self._problem_fn = problem_fn + self.io_api = read_io_api_from_problem_file(problem_fn) + return self._run_file( + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + env=env, ) - - def to_solver_model(self, model: Model, **kwargs: Any) -> Any: - raise NotImplementedError def _cache_model_labels(self, model: Model) -> None: """Cache vlabels/clabels and total label counts for label-indexed solutions.""" @@ -733,55 +772,17 @@ class CBC(Solver[None]): } ) - def solve_problem_from_model( + def _run_file( self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - msg = "Direct API not implemented for CBC" - raise NotImplementedError(msg) - - def solve_problem_from_file( - self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the CBC solver. - - The function reads the linear problem file and passes it to the solver. - If the solution is successful it returns variable solutions - and constraint dual values. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path - Path to the solution file. This is necessary for solving with CBC. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - Environment for the solver - - Returns - ------- - Result - """ + problem_fn = self._problem_fn + assert problem_fn is not None sense = read_sense_from_problem_file(problem_fn) io_api = read_io_api_from_problem_file(problem_fn) @@ -937,59 +938,17 @@ class GLPK(Solver[None]): } ) - def solve_problem_from_model( - self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - msg = "Direct API not implemented for GLPK" - raise NotImplementedError(msg) - - def solve_problem_from_file( + def _run_file( self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the glpk solver. - - This function reads the linear problem file and passes it to the - glpk solver. If the solution is successful it returns variable solutions - and constraint dual values. - - For more information on the glpk solver options, see - - https://kam.mff.cuni.cz/~elias/glpk.pdf - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path - Path to the solution file. This is necessary for solving with GLPK. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - Environment for the solver - - Returns - ------- - Result - """ + problem_fn = self._problem_fn + assert problem_fn is not None CONDITION_MAP = { "integer optimal": "optimal", "integer undefined": "infeasible_or_unbounded", @@ -1135,14 +1094,15 @@ class Highs(Solver[None]): } ) - def to_solver_model( + def _build_direct( self, - model: Model, explicit_coordinate_names: bool = False, set_names: bool = True, log_fn: Path | None = None, **kwargs: Any, - ) -> highspy.Highs: + ) -> None: + model = self.model + assert model is not None if self.solver_options.get("solver") in [ "simplex", "ipm", @@ -1166,7 +1126,6 @@ def to_solver_model( self.io_api = "direct" self.sense = model.sense self._cache_model_labels(model) - return h @staticmethod def _build_solver_model( @@ -1236,33 +1195,6 @@ def _build_solver_model( return h - def solve_problem_from_model( - self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - self.to_solver_model( - model, - explicit_coordinate_names=explicit_coordinate_names, - set_names=set_names, - log_fn=log_fn, - ) - - return self._solve( - self.solver_model, - solution_fn, - warmstart_fn, - basis_fn, - io_api="direct", - sense=model.sense, - ) - def _run_direct( self, solution_fn: Path | None = None, @@ -1281,41 +1213,17 @@ def _run_direct( sense=self.sense, ) - def solve_problem_from_file( + def _run_file( self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the HiGHS solver. - Reads a linear problem file and passes it to the HiGHS solver. - If the solution is feasible the function returns the - objective, solution and dual constraint variables. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - Environment for the solver - - Returns - ------- - Result - """ - + problem_fn = self._problem_fn + assert problem_fn is not None problem_fn_ = path_to_string(problem_fn) h = highspy.Highs() self._set_solver_params(h, log_fn) @@ -1504,14 +1412,15 @@ def _resolve_env(self, env: gurobipy.Env | dict[str, Any] | None) -> gurobipy.En self.env = resolved return resolved - def to_solver_model( + def _build_direct( self, - model: Model, explicit_coordinate_names: bool = False, env: gurobipy.Env | dict[str, Any] | None = None, set_names: bool = True, **kwargs: Any, - ) -> gurobipy.Model: + ) -> None: + model = self.model + assert model is not None env_ = self._resolve_env(env) m = self._build_solver_model( model, @@ -1523,7 +1432,6 @@ def to_solver_model( self.io_api = "direct" self.sense = model.sense self._cache_model_labels(model) - return m @staticmethod def _build_solver_model( @@ -1589,33 +1497,6 @@ def add_sos(s: xr.DataArray, sos_type: int, sos_dim: str) -> None: gm.update() return gm - def solve_problem_from_model( - self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: gurobipy.Env | dict[str, Any] | None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - self.to_solver_model( - model, - explicit_coordinate_names=explicit_coordinate_names, - env=env, - set_names=set_names, - ) - return self._solve( - self.solver_model, - solution_fn=solution_fn, - log_fn=log_fn, - warmstart_fn=warmstart_fn, - basis_fn=basis_fn, - io_api="direct", - sense=model.sense, - ) - def _run_direct( self, solution_fn: Path | None = None, @@ -1635,39 +1516,17 @@ def _run_direct( sense=self.sense, ) - def solve_problem_from_file( + def _run_file( self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: gurobipy.Env | dict[str, Any] | None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the Gurobi solver. - Reads a problem file and passes it to the Gurobi solver. - This function communicates with gurobi using the gurobipy package. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : gurobipy.Env or dict, optional - Gurobi environment for the solver, pass env directly or kwargs for creation. - - Returns - ------- - Result - """ + problem_fn = self._problem_fn + assert problem_fn is not None sense = read_sense_from_problem_file(problem_fn) io_api = read_io_api_from_problem_file(problem_fn) problem_fn_ = path_to_string(problem_fn) @@ -1851,55 +1710,17 @@ class Cplex(Solver[None]): } ) - def solve_problem_from_model( - self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - msg = "Direct API not implemented for Cplex" - raise NotImplementedError(msg) - - def solve_problem_from_file( + def _run_file( self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the cplex solver. - - This function reads the linear problem file and passes it to the cplex - solver. If the solution is successful it returns variable solutions and - constraint dual values. Cplex must be installed for using this function. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - Environment for the solver - - Returns - ------- - Result - """ + problem_fn = self._problem_fn + assert problem_fn is not None CONDITION_MAP = { "integer optimal solution": "optimal", "integer optimal, tolerance": "optimal", @@ -2014,53 +1835,17 @@ class SCIP(Solver[None]): } ) - def solve_problem_from_model( + def _run_file( self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - msg = "Direct API not implemented for SCIP" - raise NotImplementedError(msg) - - def solve_problem_from_file( - self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the scip solver. - - This function communicates with scip using the pyscipopt package. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - Environment for the solver - - Returns - ------- - Result - """ + problem_fn = self._problem_fn + assert problem_fn is not None CONDITION_MAP: dict[str, TerminationCondition] = { # https://github.com/scipopt/scip/blob/b2bac412222296ff2b7f2347bb77d5fc4e05a2a1/src/scip/type_stat.h#L40 "inforunbd": TerminationCondition.infeasible_or_unbounded, @@ -2186,56 +1971,17 @@ def runtime_features(cls) -> frozenset[SolverFeature]: return frozenset({SolverFeature.GPU_ACCELERATION}) return frozenset() - def solve_problem_from_model( - self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - msg = "Direct API not implemented for Xpress" - raise NotImplementedError(msg) - - def solve_problem_from_file( + def _run_file( self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the Xpress solver. - - This function reads the linear problem file and passes it to - the Xpress solver. If the solution is successful it returns - variable solutions and constraint dual values. The `xpress` module - must be installed for using this function. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - Environment for the solver - - Returns - ------- - Result - """ + problem_fn = self._problem_fn + assert problem_fn is not None CONDITION_MAP = { xpress.SolStatus.NOTFOUND: "unknown", xpress.SolStatus.OPTIMAL: "optimal", @@ -2368,20 +2114,6 @@ class Knitro(Solver[None]): } ) - def solve_problem_from_model( - self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - msg = "Direct API not implemented for Knitro" - raise NotImplementedError(msg) - @staticmethod def _set_option(kc: Any, name: str, value: Any) -> None: param_id = knitro.KN_get_param_id(kc, name) @@ -2418,37 +2150,17 @@ def _extract_values( return np.asarray(values, dtype=float) - def solve_problem_from_file( + def _run_file( self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the Knitro solver. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - Environment for the solver. - - Returns - ------- - Result - """ + problem_fn = self._problem_fn + assert problem_fn is not None CONDITION_MAP: dict[int, TerminationCondition] = { 0: TerminationCondition.optimal, -100: TerminationCondition.suboptimal, @@ -2633,40 +2345,6 @@ class Mosek(Solver[None]): } ) - def solve_problem_from_model( - self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - if env is not None: - warnings.warn( - "The 'env' parameter in solve_problem_from_model is deprecated and will be " - "removed in a future version. MOSEK now uses the global environment " - "automatically, avoiding unnecessary license checkouts.", - DeprecationWarning, - stacklevel=2, - ) - self.to_solver_model( - model, - explicit_coordinate_names=explicit_coordinate_names, - set_names=set_names, - ) - return self._solve( - self.solver_model, - solution_fn=solution_fn, - log_fn=log_fn, - warmstart_fn=warmstart_fn, - basis_fn=basis_fn, - io_api="direct", - sense=model.sense, - ) - def _run_direct( self, solution_fn: Path | None = None, @@ -2686,13 +2364,14 @@ def _run_direct( sense=self.sense, ) - def to_solver_model( + def _build_direct( self, - model: Model, explicit_coordinate_names: bool = False, set_names: bool = True, **kwargs: Any, - ) -> mosek.Task: + ) -> None: + model = self.model + assert model is not None self.close() self._env_stack = contextlib.ExitStack() task = self._env_stack.enter_context(mosek.Task()) @@ -2706,7 +2385,6 @@ def to_solver_model( self.io_api = "direct" self.sense = model.sense self._cache_model_labels(model) - return m @staticmethod def _build_solver_model( @@ -2798,47 +2476,17 @@ def _build_solver_model( task.putobjsense(mosek.objsense.minimize) return task - def solve_problem_from_file( + def _run_file( self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the MOSEK solver. Both mps and - lp files are supported; MPS does not support quadratic terms. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional, deprecated - Deprecated. This parameter is ignored. MOSEK now uses the global - environment automatically. Will be removed in a future version. - - Returns - ------- - Result - """ - if env is not None: - warnings.warn( - "The 'env' parameter in solve_problem_from_file is deprecated and will be " - "removed in a future version. MOSEK now uses the global environment " - "automatically, avoiding unnecessary license checkouts.", - DeprecationWarning, - stacklevel=2, - ) + problem_fn = self._problem_fn + assert problem_fn is not None self.close() self._env_stack = contextlib.ExitStack() m = self._env_stack.enter_context(mosek.Task()) @@ -3101,51 +2749,17 @@ class COPT(Solver[None]): } ) - def solve_problem_from_model( - self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - msg = "Direct API not implemented for COPT" - raise NotImplementedError(msg) - - def solve_problem_from_file( + def _run_file( self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the COPT solver. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - COPT environment for the solver - - Returns - ------- - Result - """ + problem_fn = self._problem_fn + assert problem_fn is not None # conditions: https://guide.coap.online/copt/en-doc/constant.html#chapconst-solstatus CONDITION_MAP = { 0: "unstarted", @@ -3259,52 +2873,17 @@ class MindOpt(Solver[None]): } ) - def solve_problem_from_model( + def _run_file( self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - msg = "Direct API not implemented for MindOpt" - raise NotImplementedError(msg) - - def solve_problem_from_file( - self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the MindOpt solver. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - MindOpt environment for the solver - - Returns - ------- - Result - - """ + problem_fn = self._problem_fn + assert problem_fn is not None CONDITION_MAP = { -1: "error", 0: "unknown", @@ -3433,42 +3012,17 @@ class cuPDLPx(Solver[None]): } ) - def solve_problem_from_file( + def _run_file( self, - problem_fn: Path, solution_fn: Path | None = None, log_fn: Path | None = None, warmstart_fn: Path | None = None, basis_fn: Path | None = None, env: EnvType | None = None, + **kw: Any, ) -> Result: - """ - Solve a linear problem from a problem file using the solver cuPDLPx. - cuPDLPx does not currently support its own file IO, so this function - reads the problem file using linopy (only support netcf files) and - then passes the model to cuPDLPx for solving. - If the solution is feasible the function returns the - objective, solution and dual constraint variables. - - Parameters - ---------- - problem_fn : Path - Path to the problem file. - solution_fn : Path, optional - Path to the solution file. - log_fn : Path, optional - Path to the log file. - warmstart_fn : Path, optional - Path to the warmstart file. - basis_fn : Path, optional - Path to the basis file. - env : None, optional - Environment for the solver - - Returns - ------- - Result - """ + problem_fn = self._problem_fn + assert problem_fn is not None logger.warning( "cuPDLPx doesn't currently support file IO. Building model from file using linopy." ) @@ -3480,8 +3034,9 @@ def solve_problem_from_file( msg = "linopy currently only supports reading models from netcdf files. Try using io_api='direct' instead." raise NotImplementedError(msg) - return self.solve_problem_from_model( - model, + self.model = model + self._build_direct() + return self._run_direct( solution_fn=solution_fn, log_fn=log_fn, warmstart_fn=warmstart_fn, @@ -3489,30 +3044,9 @@ def solve_problem_from_file( env=env, ) - def solve_problem_from_model( - self, - model: Model, - solution_fn: Path | None = None, - log_fn: Path | None = None, - warmstart_fn: Path | None = None, - basis_fn: Path | None = None, - env: EnvType | None = None, - explicit_coordinate_names: bool = False, - set_names: bool = True, - ) -> Result: - self.to_solver_model(model) - - return self._solve( - self.solver_model, - solution_fn=solution_fn, - log_fn=log_fn, - warmstart_fn=warmstart_fn, - basis_fn=basis_fn, - io_api="direct", - sense=model.sense, - ) - - def to_solver_model(self, model: Model, **kwargs: Any) -> cupdlpx.Model: + def _build_direct(self, **kwargs: Any) -> None: + model = self.model + assert model is not None if model.type in ["QP", "MILP"]: msg = "cuPDLPx does not currently support QP or MILP problems." raise NotImplementedError(msg) @@ -3528,7 +3062,6 @@ def to_solver_model(self, model: Model, **kwargs: Any) -> cupdlpx.Model: self.io_api = "direct" self.sense = model.sense self._cache_model_labels(model) - return cu_model @staticmethod def _build_solver_model(model: Model) -> cupdlpx.Model: From 7e89d3158fe40c3a586128121d721fea512264a3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 10:24:06 +0000 Subject: [PATCH 27/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- linopy/solvers.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 1fb22e09..e76691a4 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -506,18 +506,13 @@ def _build_file(self, **build_kwargs: Any) -> None: raise ValueError( f"Keyword argument `io_api` has to be one of {IO_APIS} or None" ) - explicit_coordinate_names = build_kwargs.pop( - "explicit_coordinate_names", False - ) + explicit_coordinate_names = build_kwargs.pop("explicit_coordinate_names", False) slice_size = build_kwargs.pop("slice_size", 2_000_000) progress = build_kwargs.pop("progress", None) problem_fn = build_kwargs.pop("problem_fn", None) if problem_fn is None: problem_fn = model.get_problem_file(io_api=io_api) - if ( - not self.supports(SolverFeature.LP_FILE_NAMES) - and explicit_coordinate_names - ): + if not self.supports(SolverFeature.LP_FILE_NAMES) and explicit_coordinate_names: logger.warning( f"{self.solver_name.value} does not support writing names to " "lp files, disabling it." @@ -658,7 +653,9 @@ def solve_problem_from_file( DeprecationWarning, stacklevel=2, ) - problem_fn = Path(problem_fn) if not isinstance(problem_fn, Path) else problem_fn + problem_fn = ( + Path(problem_fn) if not isinstance(problem_fn, Path) else problem_fn + ) self._problem_fn = problem_fn self.io_api = read_io_api_from_problem_file(problem_fn) return self._run_file(