Skip to content

Persistent solver: in-place model updates via ModelDiff #699

@FabianHofmann

Description

@FabianHofmann

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)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions