Motivation
Realistic optimization workflows — rolling-horizon, MPC, scenario sweeps, parameter studies — re-solve the same structural model many times with small mutations (bounds, rhs, coefficient values, objective coeffs). Today every Model.solve() rebuilds the native solver model from scratch and discards warm-start information. For large models the build dominates total wall-clock.
Persistent solver support would let users mutate a Linopy model and re-solve against the existing native handle, applying only the changed pieces.
Goal
Re-solve a mutated Linopy model by updating the existing native solver_model in place where safe, falling back to a rebuild otherwise. Diff at the Linopy (xarray) level using labels as the only bridge to solver positions.
Linopy data structures stay solver-agnostic; persistent state lives on the Solver, not on Model.
Proposed API
Solver.solve(model=None, assign=False) is the load-bearing primitive: when called with a model, it diffs the snapshot against the new model, applies in-place where possible, rebuilds otherwise, re-snapshots, and runs. With assign=True the Result is written back to the model via assign_result.
In-place mutation of one model:
solver = Solver.from_name("gurobi", m, io_api="direct")
solver.solve(assign=True) # initial build + snapshot
m.variables.x.lower[...] = new_lb
solver.solve(assign=True) # diff, apply, warm-started run
solver.close()
Scenario sweep across multiple models with identical structure:
solver = Solver.from_name("gurobi", m1, io_api="direct")
solver.solve(assign=True)
for mk in (m2, m3, m4):
solver.solve(mk, assign=True)
solver.close()
io_api=\"direct\" is required (file-based APIs have no native handle to update). Model.solve(...) keeps its current behavior. A lower-level Solver.update(model, apply=False) returns the computed ModelDiff for inspection.
Scope (Stage 1)
In-place updates:
| change |
path |
| variable lb / ub |
update |
| variable type (cont/bin/int) |
update* |
| constraint rhs |
update |
| constraint sign |
update* |
| objective linear coeffs |
update |
| objective sense |
update |
| constraint coefficient values, sparsity unchanged |
update |
Rebuild triggers:
- coefficient sparsity change
- quadratic objective change
- variables/constraints added or removed
- mask flip (any active-row set change)
- coordinate reindex with same labels
- backend raises
UnsupportedUpdate
* Type/sign flips are backend-dependent; backends opt out via UnsupportedUpdate.
Backends (Stage 1): Gurobi and HiGHS. Other direct backends (Xpress, Mosek) opt in later; file-based backends are not eligible.
Sketch of the approach
- Snapshot.
Solver holds a per-container ModelSnapshot with label-keyed deep copies of bounds, rhs, sign, objective linear; plus a canonicalized CSR pattern (indptr, indices) per constraint container. No materialization of the full A matrix. Structural fingerprint via references to label_index.vlabels/clabels (cheap np.array_equal).
- Diff. Pure function
compute_diff(snapshot, model) -> ModelDiff. Structural mismatch → rebuild. Otherwise compare lb/ub/rhs/sign per container via xarray !=, and canonical CSR patterns per dirty container.
- Coefficient dirty tracking. Add a per-
Constraint _coef_dirty flag flipped by the coeffs/vars/lhs setters; rhs.setter gets a structural short-circuit for pure-constant rhs so it does not falsely set the flag. Cross-model sweeps treat all constraints as potentially dirty.
- Backend interface.
Solver.supports_persistent_update: ClassVar[bool] + apply_update(diff, var_label_index, con_label_index). Validate the full diff; on any write-phase exception discard the native handle and rebuild. Snapshot only refreshes on success.
- Module layout. New
linopy/persistent/{snapshot,diff,errors}.py for the pure logic; backend apply_update methods live on existing solver classes in linopy/solvers.py.
- Observability. Counters on
Solver: _rebuilds, _in_place_updates, _last_rebuild_reason (enum, not string).
Out of scope (Stage 2+)
- Append-only variable/constraint additions without invalidation
- Removal via column/row deletion in the native model
- Quadratic objective in-place updates
- Warm-start basis preservation across sign/type changes
- Mask toggle as an in-place update (depends on a build-side change first)
Motivation
Realistic optimization workflows — rolling-horizon, MPC, scenario sweeps, parameter studies — re-solve the same structural model many times with small mutations (bounds, rhs, coefficient values, objective coeffs). Today every
Model.solve()rebuilds the native solver model from scratch and discards warm-start information. For large models the build dominates total wall-clock.Persistent solver support would let users mutate a Linopy model and re-solve against the existing native handle, applying only the changed pieces.
Goal
Re-solve a mutated Linopy model by updating the existing native
solver_modelin place where safe, falling back to a rebuild otherwise. Diff at the Linopy (xarray) level using labels as the only bridge to solver positions.Linopy data structures stay solver-agnostic; persistent state lives on the
Solver, not onModel.Proposed API
Solver.solve(model=None, assign=False)is the load-bearing primitive: when called with amodel, it diffs the snapshot against the new model, applies in-place where possible, rebuilds otherwise, re-snapshots, and runs. Withassign=TruetheResultis written back to the model viaassign_result.In-place mutation of one model:
Scenario sweep across multiple models with identical structure:
io_api=\"direct\"is required (file-based APIs have no native handle to update).Model.solve(...)keeps its current behavior. A lower-levelSolver.update(model, apply=False)returns the computedModelDifffor inspection.Scope (Stage 1)
In-place updates:
Rebuild triggers:
UnsupportedUpdate* Type/sign flips are backend-dependent; backends opt out via
UnsupportedUpdate.Backends (Stage 1): Gurobi and HiGHS. Other direct backends (Xpress, Mosek) opt in later; file-based backends are not eligible.
Sketch of the approach
Solverholds a per-containerModelSnapshotwith label-keyed deep copies of bounds, rhs, sign, objective linear; plus a canonicalized CSR pattern (indptr,indices) per constraint container. No materialization of the fullAmatrix. Structural fingerprint via references tolabel_index.vlabels/clabels(cheapnp.array_equal).compute_diff(snapshot, model) -> ModelDiff. Structural mismatch → rebuild. Otherwise compare lb/ub/rhs/sign per container via xarray!=, and canonical CSR patterns per dirty container.Constraint_coef_dirtyflag flipped by thecoeffs/vars/lhssetters;rhs.settergets a structural short-circuit for pure-constant rhs so it does not falsely set the flag. Cross-model sweeps treat all constraints as potentially dirty.Solver.supports_persistent_update: ClassVar[bool]+apply_update(diff, var_label_index, con_label_index). Validate the full diff; on any write-phase exception discard the native handle and rebuild. Snapshot only refreshes on success.linopy/persistent/{snapshot,diff,errors}.pyfor the pure logic; backendapply_updatemethods live on existing solver classes inlinopy/solvers.py.Solver:_rebuilds,_in_place_updates,_last_rebuild_reason(enum, not string).Out of scope (Stage 2+)