Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 ``mip_gap`` 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
48 changes: 47 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,46 @@ 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.
"""

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

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 +285,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 +298,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