refactor: stateful Solver instances and two-step solve API#682
Conversation
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).
Surfaces solver name, status, io_api, and solution/report summary.
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.
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.
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.
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.
…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.
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.
for more information, see https://pre-commit.ci
|
@FabianHofmann I suggest checking the new class |
|
Can we make the solver class into a data class, too? And get rid of this strange instantiate solver_class(**solver_options) pattern. I am not sure whether there is a benefit to holding a solver class instance without a model attached, so what about constructors like: which dispatches to which dispatches according to io_api. I'd say this then gets rid of any case for prepare_solver or some such. |
|
Benchmark: OETC should be able to become a solver class. class Model:
...
def solve(solver_name, io_api, **solver_options):
self.solver = None
self.solver = solver = Solver.from_name(solver_name, model=self, io_api=io_api, options=solver_options)
result = solver.solve()
self.apply_result(result) |
|
Solvers should get a class method: We then use a derived Collection pattern similar to the following for available_solvers so not to grab licenses prematurely. |
|
ah yes, and OETC and some Gurobi compute like instances can have an asynchronous solving option. Where you do not want to block the process, but return early and only give back some sort of job identifier in hand with which to retrieve the solution later. Gurobi docs: https://docs.gurobi.com/projects/optimizer/en/current/features/batchoptimization.html And i don't mean implement this now, here, but the interface should be extensible to allow for that. |
|
i am unsure how to effectively keep track of the state that was communicated to the solver. ie. when you then do modifications on the linopy model data after it was communicated to the solver. when do you send those updates (and which updates) the most promising way in my mind would be the following: the solver object holds a shallow copy of the linopy model (up until the individual constraint and variable objects). EDIT: probably only of the constraints and variable objects. when you make an update to a variable bound or constraint you use some sort of cow to create a copy in the linopy model (this mostly means something like a v.data = v.data.assign pattern and maybe on mutable constraints and rhs an explicit cow numpy flag). this means you share variable data/constraint data until you make the first modification. then you do several nice characteristics that way:
|
|
@FabianHofmann i reread the pr description. don't use vlabels, clabels through m.matrices. m.matrices is expensive when constraints are not frozen. but I think you know and that's why they are stored on solver, isn't it |
thanks @FBumann for raising this. I pulled over the dual_bound feature from your pr. should cover all if it then |
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.
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.
for more information, see https://pre-commit.ci
Module import no longer probes license-managed solvers (Gurobi, Mosek, Knitro, MindOpt, COPT, cuPDLPx). available_solvers / quadratic_solvers are now lazy Sequence proxies; license probes move to Solver.license_status() / check_solver_licenses(). Solver packages are loaded lazily via a _LazyModule proxy.
for more information, see https://pre-commit.ci
- Rename Model.apply_result() to Model.assign_result() - Update all test function names to reflect new method name - Update release notes documentation
Filters available_solvers by Solver.license_status().ok so tests and runtime selection can skip installed-but-unlicensed solvers cleanly. Exported from linopy. Tests parametrize over it instead of available_solvers. Also fixes a case-mismatch in a knitro test regex.
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
doctest collection probed __wrapped__ via inspect.unwrap, triggering imports of optional solver packages (pyscipopt, cupdlpx). Restrict _LazyModule.__getattr__ to non-dunder names. Add narrowing asserts and type fixes uncovered by mypy.
for more information, see https://pre-commit.ci
|
|
||
| *Inspect the solver after solving* | ||
|
|
||
| * After ``model.solve()``, the solver object stays available on ``model.solver``. You can inspect it, reuse it, or release the underlying solver (and its license) by calling ``model.solver.close()`` or assigning ``model.solver = None``. It is also released automatically when the model is garbage-collected. |
There was a problem hiding this comment.
Thats a great and important note for commercial usage, as the license is blocked otherwise. We might want to add a notebook about the new solver stuff, as an example with highs or gurobi...? If you want i can craf one, exploring the new feature on the way and you can add what i missed?
There was a problem hiding this comment.
cool, sounds good!
|
@FBumann I am done with my features for this pr. another will follow for the persistent solve. since jonas is on holiday he won't be able to make another review here. do you want to take another look? focus could be breaking changes and whether flexopt tests pass with it (pypsa does). feel free to pull in the notebook here or to raise another pr in case you are able to quickly approve (no pressure anyway) |
|
@FabianHofmann No, i think a followup for the notebook is better anyway. |
|
@FabianHofmann All tests pass. |
|
wonderful! thanks @FBumann |
Covers `model.solver` inspection, `SolverReport`, the construct-then-solve API (`Solver.from_name(...).solve()` + `model.assign_result`), solver capabilities via `SolverFeature`, and `available_solvers` vs `licensed_solvers` (see #682). Also extends `create-a-model.ipynb` with a small tail demoing `model.solver`, the report, and `solver.close()`, linking forward to the new page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sos): add Model.apply/undo_sos_reformulation methods Introduce a stateful pair of methods on Model that own the SOS reformulation lifecycle: - apply_sos_reformulation() stashes the reformulation token on the model (new _sos_reformulation_state attribute). Raises if already applied. - undo_sos_reformulation() reads the stashed token and restores the original SOS form. No-op if nothing is applied. Model.solve(reformulate_sos=...) now delegates to these methods rather than threading the token through local state. The Solver path (which was previously raising via Model.solve's pre-flight check) now gets a clean ValueError directly from Solver._build() when an SOS-bearing model is handed to a solver without native SOS support — making the low-level API safe to use independently of Model.solve. Persistence: - copy() (and copy.copy / copy.deepcopy) carry the reformulation token with a deepcopy, so the copy is independently undoable. - to_netcdf() raises if a reformulation is active; users must undo first to serialize a stable model state. Context: motivated by the same investigation as #688 — while reviewing the new Solver.from_model() API surface introduced by #682, the SOS reformulation lifecycle stood out as load-bearing orchestration that the Solver path couldn't reproduce. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(solver): validation, sanitize kwargs, and result wiring on Solver path (#691) * refactor(solver): lift feature checks + sanitize/wiring to Solver path Make Solver.from_name(...).solve() a real first-class entry point that doesn't lose Model.solve()'s safety nets: - Lift solver-feature gates into Solver._build() via a new _validate_model() hook: quadratic models against LP-only solvers and semi-continuous variables against solvers that don't support them. Removed the duplicate checks from Model.solve(). - Add sanitize_zeros / sanitize_infinities kwargs to Solver.from_model() (default True). The kwargs are processed in _build() before dispatch, so both file and direct io_apis honor them. Model.solve() forwards the kwargs through instead of pre-mutating the constraints itself. - Extend Model.assign_result(result, solver=None) so the Solver-path canonical pattern works: solver = Solver.from_name(...); result = solver.solve(); model.assign_result(result, solver=solver). When the solver kwarg is provided, model.solver gets wired the same way Model.solve() wires it, so compute_infeasibilities() and friends keep working through the low-level API. The empty-objective check stays on Model.solve() — to_gurobipy() / to_highspy() and similar build-only converters legitimately work against objectiveless models (gurobi/highs default to a zero objective), so the check belongs at the actual submit point. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * move empty-objective check to Solver.solve() for entry-point parity The empty-objective UX guardrail was previously only on Model.solve(), leaving the lower-level Solver.from_name(...).solve() path with a silent gap. Move it to Solver.solve() — the actual submit primitive that both entry points go through — so the same check fires regardless of which API the user reaches for. Build-time translate-only paths (to_gurobipy(), to_highspy(), to_file()) are unaffected since they don't call solve(). The cost of catching the error after build instead of before is bounded and only hits a programming-error case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: parametrize empty-objective check across both entry points Consolidate the Model.solve() and Solver.from_name(...).solve() tests into one parametrized case — same check, two callers, one assertion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: collapse parametrize to a single test with two raises blocks Same property tested twice — no need for separate test IDs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * preserve empty-objective check for remote-solve path in Model.solve() The remote-solve branch in Model.solve() short-circuits to a RemoteHandler before reaching Solver.solve(), so the check now in Solver.solve() doesn't cover it. Restore the early raise in Model.solve() so behavior is unchanged for all Model.solve() callers (mock, remote, local) while Solver.solve() still covers direct-Solver callers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * move remote-path empty-objective check inside the remote branch The early-position check was a workaround: the remote branch short-circuits before Solver.solve() (where the canonical check now lives), so empty-objective with remote=... wouldn't raise. Moving it into the remote branch itself makes the intent local to where it's needed, with a comment pointing at #683 where this duplication disappears once OETC becomes a Solver subclass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * keep sanitize on Model; Solver.from_model() stays mutation-free Remove the sanitize_zeros / sanitize_infinities kwargs from Solver.from_model(). The Solver builder now never mutates the model. Sanitization is exposed where it has always lived — model.constraints.sanitize_zeros() / .sanitize_infinities() — and Model.solve() calls them inline as part of its orchestration. Rationale: model-state transformations should be Model-level primitives (matches the SOS reformulation pattern from #690). The Solver's job is to translate the model and run; it should not silently change the caller's model on the way in. Users who go through the lower-level Solver path apply sanitize explicitly when they want it. Replaces TestSanitizeKwargs with TestSolverDoesNotMutateModel, pinning the mutation-free invariant: building a Solver against a model with a near-zero coefficient leaves model.constraints["c"].coeffs unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address review: SOS hint, lp_only_solver fixture, assign_result doc --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Fabian <fab.hof@gmx.de> * refactor(sos): tighten undo semantics and error hints - undo_sos_reformulation() now raises if no state is applied (fail-fast) - to_netcdf error no longer suggests poking the private state slot - Solver._build runs _validate_model before _check_sos_unmasked so SOS on an LP-only solver surfaces the reformulate-first hint - reformulate_sos_constraints docstring points at the stateful API * fix(sos): auto-undo SOS reformulation when build/solve raises `Model.solve(reformulate_sos=...)` left `_sos_reformulation_state` set if `Solver.from_name`, `solver.solve`, or the file-cleanup `finally` raised, since the undo lived in a second `try` around `assign_result` that those failures never reached. The next solve then hit `RuntimeError: SOS reformulation has already been applied`. Wrap sanitize, build/solve, file cleanup, and assign_result in a single outer try/finally so the undo always runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sos): support reformulation through remote/oetc netcdf path `to_netcdf` previously raised when the model had an active SOS reformulation, blocking `Model.solve(remote=...)` for users who passed `reformulate_sos`. Beyond the raise, the remote branch was silently ignoring `reformulate_sos` entirely. Changes: - `to_netcdf` warns (UserWarning) instead of raising; the reformulated MILP form is what gets serialized. The `_sos_reformulation_state` token is not persisted — it lives only on the in-memory caller's Model, where the apply/undo bracket keeps its lifecycle intact. - `Model.solve(remote=...)` now brackets the remote dispatch with `apply_sos_reformulation` / `undo_sos_reformulation`, exactly like the local path. The `to_netcdf` warning emitted inside the remote helper is suppressed via `warnings.catch_warnings`. - New `Model._resolve_sos_reformulation(solver_name, reformulate_sos)` helper deduplicates the should-reformulate decision between the local and remote branches and uses `solver_supports(...)` instead of the ad-hoc `getattr(solvers, SolverName(...).name)` pattern. - `solver_name=None` with `reformulate_sos="auto"` now raises a sharp error pointing users at either passing `solver_name=...` or using `True`/`False` to skip the lookup. The local path is unaffected because its existing default (`solver_name = available_solvers[0]`) runs before the helper sees None. Addresses the open thread on #690 from FabianHofmann. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(sos): fix mypy errors on remote-bracket and resolve tests - Drop now-unused type: ignore on _resolve_sos_reformulation call where mypy correctly narrows (True, False, "auto") to bool | Literal["auto"]. - Type _fake_handler as RemoteHandler via cast so the three Model.solve(remote=handler, ...) calls satisfy the RemoteHandler | OetcHandler | None signature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(sos): move reformulation lifecycle into remote handlers * fix(types): tighten reformulate_sos to bool | Literal["auto"] * test(ssh): cover SOS bracket in RemoteHandler.solve_on_remote --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Fabian <fab.hof@gmx.de>
closes #628 #583
Changes proposed in this Pull Request
Refactor of the solver layer to put solver state on a stateful
Solverinstance and expose a clean construct-then-solve workflow.SolveronModel.solver. Solver state (native model, results) now lives on aSolverinstance attached toModel.solver.Model.solver_modelandModel.solver_namebecome read-only properties delegating tomodel.solver(assigning anything butNoneraises; settingNonecloses the solver).Model.solver_namemay beNonebefore a solve. These two properties are candidates for future deprecation. The solver exposes an explicitclose()(also auto-invoked from__del__) that releases the native handle and any held license.Solver.from_name(name, model, io_api=..., options=...)(orSolverClass.from_model(model, ...)), then callsolver.solve()to run and obtain aResult, andmodel.assign_result(result)to write the solution back.Solveris a dataclass; subclasses no longer need__init__overrides._build_direct(**kwargs)(build the native model fromself.model),_run_direct(**kwargs)(run the prebuilt native model), and_run_file(**kwargs)(invoke the solver onself._problem_fn). File-only solvers (CBC, GLPK, Cplex, SCIP, Xpress, Knitro, COPT, MindOpt) override only_run_file. Direct-API solvers (Highs, Gurobi, Mosek, cuPDLPx) override all three.Solver.solve_problem,Solver.solve_problem_from_model, andSolver.solve_problem_from_fileare kept on the base class as thin shims that route through the new pipeline and emitDeprecationWarning. To be removed in a future release.Model-bound helpers (model.to_gurobipy(),model.to_highspy(),to_cupdlpx(model)), or directly viaSolver.from_model(model, io_api=\"direct\").solver_model. The previous publicSolver.to_solver_modelmethod is removed and folded into the internal_build_directhook to avoid exposing a third redundant path.features: frozenset[SolverFeature]ClassVars on eachSolversubclass (with an optionalruntime_features()classmethod for version-dependent ones); query withSolver.supports(feature).SolverFeatureis exported fromlinopy(andlinopy.solvers);linopy.solver_capabilitiesremains as a back-compat shim with a lazySOLVER_REGISTRYmapping andsolver_supports()helper.linopy.available_solversis now lazy and no longer probes license-managed solvers (Gurobi, Mosek, Knitro, MindOpt, COPT, cuPDLPx) at import time — membership now means "package/binary installed", not "working license". Same lazy behaviour forquadratic_solvers. Newlinopy.licensed_solversreturns the installed solvers that currently pass a license probe. Newlinopy.solvers.check_solver_licenses(*names), plus per-classSolverClass.is_available()andSolverClass.license_status()returning aLicenseStatusdataclass (name,ok,message). All caches are refreshable via.refresh().Resultcarries solver report.Resultgainssolver_nameandreport: SolverReport | None(runtime, MIP gap, dual bound, iteration counts) and prints them in__repr__. CBC, HiGHS, Gurobi, Knitro, and cuPDLPx populatereport; when possible also populate the MIPdual_bound.SolverReportis exported fromlinopy.constants.Solution.primalandSolution.dualare now densenp.ndarrays indexed by linopy label (length =max_label + 1); masked or solver-dropped slots areNaN. Previouslypd.Serieskeyed by name. Each solver emits arrays in this label-indexed form — direct-API solvers via cached_vlabels/_clabelspopulated at_build_directtime, file-based solvers via the shared_solution_from_nameshelper. Robust to solver iteration order and to solvers dropping unused variables (e.g. CPLEX on LP read). The dense numpy arrays are assigned toModel.solutionby simple reshaping.Constraintsexposes a cachedlabel_index(mirroringVariables.label_index), soassign_resultand IIS extraction no longer trigger a fullmodel.matricesrebuild. Invalidated on add/remove.ConstraintBasegainsactive_labels()and arangeproperty, andCSRConstraintexposescoordsto support this without forcing a CSR build.linopy.commongainsvalues_to_lookup_arrayandConstraintLabelIndex; the legacy pandas-keyed helpersseries_to_lookup_arrayandlookup_valsare removed.Reviewer requests
SolverReportwithSolverMetrics(feat: add unified SolverMetrics #583); exposedual_bound(best bound)Solvera dataclass; drop thesolver_class(**solver_options)patternSolver.from_name(name, model, io_api=..., options=...)static constructor +SolverClass.from_model(...)classmethodprepare_solver/run_solvertwo-step APIvlabels/clabelson the solver instead of going throughmodel.matrices, see vlabels-clabels-flow.html for more detailsModel.solve = self.solver = Solver.from_name(...); assign_result(...)pattern inModel.solveis_available()classmethod per solver + lazySOLVER_REGISTRYsoavailable_solversdiscovery doesn't grab licenses prematurely@coroa: refactor OETC as a(Refactor OETC as aSolversubclassSolversubclass #683)@coroa: keep the interface extensible for asynchronous solving (Gurobi batch optimization, OETC) — return early with a job handle and retrieve later(Refactor OETC as aSolversubclass #683)@coroa: incremental update path — solver holds a shallow copy of the linopy model,(defer to follow-up pr)solver.update(model)diffs and pushes only changed bounds/rhs/coefficients (CoW on variable/constraint data)Checklist