Skip to content

Zero culling forces model rebuilding #925

Description

@olivier-lacroix

Version Checks (indicate both or one)

  • I have confirmed this bug exists on the lastest release of Linopy.

  • I have confirmed this bug exists on the current master branch of Linopy.

Issue Description

When a constraint's LHS contains a term whose coefficient is exactly zero, linopy culls that term from the exported matrix, so the constraint's sparsity is data-dependent even though its feasible set is not.

Concretely, for the (common saturation-style) constraint

activation <= ratio * reservation ratio in [0, 1]

when ratio == 0 the term 0 * reservation is dropped, so the reservation column disappears from that row. Rebuilding the same model with ratio flipped between 0 and a non-zero value produces different LHS sparsity for the very same constraint.

The persistent / warm-start update machinery then cannot update the model in place: persistent/diff.py (diff_con, lines ~454-457) treats a change in the CSR indptr/indices as RebuildReason.SPARSITY, and Solver falls back to a full model rebuild + fresh solve (_rebuild). This defeats warm starts for any dynamic coefficient that crosses zero, which is a very common pattern (e.g. zero-price hours setting a bound to zero).

Root cause (likely introduced in #816) is the zero-coefficient culling at matrix export:

  • Constraint._matrix_export_data (linopy/constraints.py, valid_final = (vars_final != -1) & (coeffs_final != 0))
  • Constraint.flat / to_polarsmask_func (mask = (data["vars"] != -1) & (data["coeffs"] != 0))

Reproducible Example

importnumpyasnpimportlinopyfromlinopy.persistent.diffimportModelDiff, RebuildReasonfromlinopy.persistent.snapshotimportModelSnapshotfromlinopy.solversimportSolverdefbuild_model(ratios):
"""Two-timestep model: activation <= ratio * reservation."""m=linopy.Model()
activation=m.add_variables(lower=0, name="activation", coords=[np.arange(2)])
reservation=m.add_variables(lower=0, name="reservation", coords=[np.arange(2)])
con=m.add_constraints(activation<=ratios*reservation, name="activation_limit")
m.add_objective(0*activation) # feasibility problemreturnm, condefrow_nnz(m, con):
csr, _, _, _=con.to_matrix_with_rhs(m.variables.label_index)
returnnp.diff(csr.indptr).tolist()
# (1) Sparsity depends on the data: the zero coefficient is culled.m1, c1=build_model(np.array([1.0, 1.0])) # both rows: activation & reservationm2, c2=build_model(np.array([1.0, 0.0])) # row 2 coefficient == 0print("ratio=[1, 1] nnz/row =", row_nnz(m1, c1)) # [2, 2]print("ratio=[1, 0] nnz/row =", row_nnz(m2, c2)) # [2, 1] <- reservation col gone# (2) The persistent update machinery sees a sparsity change and rebuilds.snap=ModelSnapshot.capture(m1)
diff=ModelDiff.from_snapshot(snap, m2, same_model=False, ignore_dims=[])
assertdiffisRebuildReason.SPARSITYsolver=Solver.from_name("highs", model=m1, io_api="direct", track_updates=True, set_names=False)
solver.solve(assign=True) # cold solve oksolver.update(m2, ignore_dims=[]) # warm: only a coefficient changed to 0assertsolver._last_rebuild_reasonisRebuildReason.SPARSITYassertsolver._rebuilds==1# full rebuild instead of in-place updateprint("persistent solver rebuilt (RebuildReason.SPARSITY) instead of updating in place")

Output (verified on v0.9.1; the three assert lines pass, so rebuild_reason == SPARSITY and rebuilds == 1):

ratio=[1, 1] nnz/row = [2, 2]
ratio=[1, 0] nnz/row = [2, 1]
persistent solver rebuilt (RebuildReason.SPARSITY) instead of updating in place

Expected Behavior

Linopy should be smarter about the culling, or offer a way to opt-out of it where required

Installed Versions

Details - linopy 0.9.1 (latest release) - numpy 1.26.x, xarray, scipy - HiGHS 1.15.1 (via linopy)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions