Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3a3f828
New SolverMetrics dataclass (linopy/constants.py)
FBumann Feb 11, 2026
b42d2d4
Move extraction into method
FBumann Feb 11, 2026
c5322c4
Remove some metrics
FBumann Feb 11, 2026
5e2afdb
Extract safe get
FBumann Feb 11, 2026
aa811d4
Add base population
FBumann Feb 11, 2026
c614b6b
Improve docstrings
FBumann Feb 11, 2026
3b349c1
Update CHangelog
FBumann Feb 11, 2026
5e815a7
Make dataclass frozen and add some more solvers
FBumann Feb 11, 2026
cbc5a31
- Added test_solver_metrics_frozen — verifies frozen dataclass beha…
FBumann Feb 11, 2026
92054f1
Update Release notes
FBumann Feb 11, 2026
90db31f
Replace mock-based metrics tests with real solver integration tests
FBumann Feb 11, 2026
fef88b0
Add better tests actually checking if metrics are populated, and fix …
FBumann Feb 11, 2026
c405ef0
Rename to dual_bound and improve docstring
FBumann Feb 11, 2026
f135e9b
FIx testable solvers and remove for others
FBumann Feb 11, 2026
7135f14
Scope metrics tests to solvers with tested overrides
FBumann Feb 11, 2026
45f8127
Add pragma no cover for test depending on solvers not in CI
FBumann Feb 11, 2026
34549f8
Add pragma no cover for test depending on solvers not in CI
FBumann Feb 11, 2026
21f555e
Add to notebook
FBumann Feb 11, 2026
032e098
Fix Highs Metrics if LP
FBumann Feb 12, 2026
9010bc7
Merge master
FBumann Mar 9, 2026
b3f8349
Update notebook
FBumann Mar 9, 2026
edb078d
Add peak_memory field to SolverMetrics
FBumann Mar 9, 2026
e10bd80
Fix Xpress peak_memory unit conversion (bytes to MB)
FBumann Mar 9, 2026
9cf0a98
fix Release notes
FBumann Mar 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Upcoming Version
* Add the `sphinx-copybutton` to the documentation
* Add SOS1 and SOS2 reformulations for solvers not supporting them.
* Enable quadratic problems with SCIP on windows.
* Add unified ``SolverMetrics`` dataclass accessible via ``Model.solver_metrics`` after solving. Provides ``solver_name``, ``solve_time``, ``objective_value``, ``best_bound``, and ``dual_bound`` in a solver-independent way. All solvers populate solver-specific fields where available.


Version 0.6.5
Expand Down
20 changes: 18 additions & 2 deletions examples/create-a-model.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,25 @@
"cell_type": "markdown",
"id": "e296f641",
"metadata": {},
"source": "Well done! You solved your first linopy model!"
},
{
"metadata": {},
"cell_type": "markdown",
"source": [
"Well done! You solved your first linopy model!"
]
"### Solver Metrics\n",
"\n",
"After solving, you can inspect performance metrics reported by the solver via `solver_metrics`. This includes solve time, objective value, and for MIP problems, the dual bound and MIP gap (available for most solvers."
],
"id": "e4995d38f3fc7779"
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": "m.solver_metrics",
"id": "bef28e724dceba9"
}
],
"metadata": {
Expand Down
3 changes: 2 additions & 1 deletion linopy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import linopy.monkey_patch_xarray # noqa: F401
from linopy.common import align
from linopy.config import options
from linopy.constants import EQUAL, GREATER_EQUAL, LESS_EQUAL
from linopy.constants import EQUAL, GREATER_EQUAL, LESS_EQUAL, SolverMetrics
from linopy.constraints import Constraint, Constraints
from linopy.expressions import LinearExpression, QuadraticExpression, merge
from linopy.io import read_netcdf
Expand All @@ -40,6 +40,7 @@
"OetcHandler",
"QuadraticExpression",
"RemoteHandler",
"SolverMetrics",
"Variable",
"Variables",
"available_solvers",
Expand Down
52 changes: 51 additions & 1 deletion linopy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Linopy module for defining constant values used within the package.
"""

import dataclasses
import logging
from dataclasses import dataclass, field
from enum import Enum
Expand Down Expand Up @@ -235,6 +236,50 @@ class Solution:
objective: float = field(default=np.nan)


@dataclass(frozen=True)
class SolverMetrics:
"""
Unified solver performance metrics.
All fields default to ``None``. Solvers populate what they can;
unsupported fields remain ``None``. Access via
:attr:`Model.solver_metrics` after calling :meth:`Model.solve`.
Attributes
----------
solver_name : str or None
Name of the solver used.
solve_time : float or None
Wall-clock time spent solving (seconds).
objective_value : float or None
Objective value of the best solution found.
dual_bound : float or None
Best bound on the objective from the MIP relaxation (also known as
"best bound"). Only populated for integer programs.
mip_gap : float or None
Relative gap between the objective value and the dual bound.
Only populated for integer programs.
peak_memory : float or None
Peak memory usage during solving (MB). Only populated for solvers
that expose this information (e.g. Gurobi, Xpress).
"""

solver_name: str | None = None
solve_time: float | None = None
objective_value: float | None = None
dual_bound: float | None = None
mip_gap: float | None = None
peak_memory: float | None = None

def __repr__(self) -> str:
fields = []
for f in dataclasses.fields(self):
val = getattr(self, f.name)
if val is not None:
fields.append(f"{f.name}={val!r}")
return f"SolverMetrics({', '.join(fields)})"


@dataclass
class Result:
"""
Expand All @@ -244,6 +289,7 @@ class Result:
status: Status
solution: Solution | None = None
solver_model: Any = None
metrics: SolverMetrics | None = None

def __repr__(self) -> str:
solver_model_string = (
Expand All @@ -256,12 +302,16 @@ def __repr__(self) -> str:
)
else:
solution_string = "Solution: None\n"
metrics_string = ""
if self.metrics is not None:
metrics_string = f"Solver metrics: {self.metrics}\n"
return (
f"Status: {self.status.status.value}\n"
f"Termination condition: {self.status.termination_condition.value}\n"
+ solution_string
+ f"Solver model: {solver_model_string}\n"
f"Solver message: {self.status.legacy_status}"
+ metrics_string
+ f"Solver message: {self.status.legacy_status}"
)

def info(self) -> None:
Expand Down
28 changes: 28 additions & 0 deletions linopy/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
SOS_TYPE_ATTR,
TERM_DIM,
ModelStatus,
SolverMetrics,
TerminationCondition,
)
from linopy.constraints import AnonymousScalarConstraint, Constraint, Constraints
Expand Down Expand Up @@ -112,6 +113,7 @@ class Model:

solver_model: Any
solver_name: str
_solver_metrics: SolverMetrics | None
_variables: Variables
_constraints: Constraints
_objective: Objective
Expand Down Expand Up @@ -154,6 +156,7 @@ class Model:
"_force_dim_names",
"_auto_mask",
"_solver_dir",
"_solver_metrics",
"solver_model",
"solver_name",
"matrices",
Expand Down Expand Up @@ -215,6 +218,28 @@ def __init__(
)

self.matrices: MatrixAccessor = MatrixAccessor(self)
self._solver_metrics: SolverMetrics | None = None

@property
def solver_metrics(self) -> SolverMetrics | None:
"""
Solver performance metrics from the last solve, or ``None``
if the model has not been solved yet.
Returns a :class:`~linopy.constants.SolverMetrics` instance.
Fields the solver cannot provide remain ``None``.
Reset to ``None`` by :meth:`reset_solution`.
Examples
--------
>>> m.solve(solver_name="highs") # doctest: +SKIP
>>> m.solver_metrics.solve_time # doctest: +SKIP
0.003
>>> m.solver_metrics.objective_value # doctest: +SKIP
0.0
"""
return self._solver_metrics

@property
def variables(self) -> Variables:
Expand Down Expand Up @@ -1483,6 +1508,7 @@ def solve(
self.termination_condition = result.status.termination_condition.value
self.solver_model = result.solver_model
self.solver_name = solver_name
self._solver_metrics = result.metrics

if not result.status.is_ok:
return (
Expand Down Expand Up @@ -1546,6 +1572,7 @@ def _mock_solve(
self.termination_condition = TerminationCondition.optimal.value
self.solver_model = None
self.solver_name = solver_name
self._solver_metrics = SolverMetrics(solver_name="mock", objective_value=0.0)

for name, var in self.variables.items():
var.solution = xr.DataArray(0.0, var.coords)
Expand Down Expand Up @@ -1788,6 +1815,7 @@ def reset_solution(self) -> None:
"""
self.variables.reset_solution()
self.constraints.reset_dual()
self._solver_metrics = None

to_netcdf = to_netcdf

Expand Down
Loading
Loading