diff --git a/CHANGELOG.md b/CHANGELOG.md index ae37c3270..90196379a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Added type annotations to most methods on the `Model` class ### Fixed - Fixed Cython 3.3 compatibility (#1248) +- Fixed `lotsizing_lazy` example: the constraint handler now locks its variables in `conslock`, so presolve can no longer aggregate them away and make the lazily added (l,S) cuts cut off the optimum (#1228) - Made `test_markDoNotAggrVar_and_getStatus` robust to SCIP presolve changes by discovering the aggregated/multi-aggregated variables instead of hardcoding them ### Changed - Move magic methods (`__radd__`, `__sub__`, `__rsub__`, `__rmul__`, `__richcmp__`, `__neg__`, and `__rtruediv__`) to `ExprLike` base class (#1204) diff --git a/examples/finished/lotsizing_lazy.py b/examples/finished/lotsizing_lazy.py index d39101f15..57c3ddbfe 100644 --- a/examples/finished/lotsizing_lazy.py +++ b/examples/finished/lotsizing_lazy.py @@ -33,7 +33,8 @@ def addcut(self, checkonly, sol): if checkonly: return True else: - # add cutting plane constraint + # add the (l,S) inequality sum_{t in S} x[t] <= sum_{t in S} D[t,l] y[t] + I[l] + # with I[l] eliminated through the flow conservation constraints (I[0] = 0) self.model.addCons(quicksum([x[t] for t in L]) + \ quicksum(D[t, ell] * y[t] for t in S) >= D[1, ell], removable=True) @@ -53,7 +54,16 @@ def consenfolp(self, constraints, nusefulconss, solinfeasible): return {"result": SCIP_RESULT.FEASIBLE} def conslock(self, constraint, locktype, nlockspos, nlocksneg): - pass + # With needscons=False, SCIP calls this once with constraint=None and expects the + # handler to lock every variable it enforces. The (l,S) inequalities only have + # positive coefficients and are ">=" constraints, so they can only be violated by + # rounding x or y down. Without these locks, presolve is free to apply dual + # reductions (e.g. aggregating the relaxed y[t] away), and the cuts added later + # would then cut off the true optimum. + y, x, I = self.model.data + for t in self.data[1]: + self.model.addVarLocksType(y[t], locktype, nlockspos, nlocksneg) + self.model.addVarLocksType(x[t], locktype, nlockspos, nlocksneg) def sils(T, f, c, d, h): @@ -160,7 +170,6 @@ def mk_example(): conshdlr = Conshdlr_sils() model = sils_cut(T, f, c, d, h, conshdlr) - model.setBoolParam("misc/allowstrongdualreds", 0) model.optimize() sils_cut_obj = model.getObjVal() y, x, I = model.data diff --git a/tests/test_conshdlr.py b/tests/test_conshdlr.py index 3fcf0b37d..4717131b1 100644 --- a/tests/test_conshdlr.py +++ b/tests/test_conshdlr.py @@ -1,3 +1,6 @@ +import os +import runpy + from pyscipopt import Model, Conshdlr, SCIP_RESULT, SCIP_PRESOLTIMING, SCIP_PROPTIMING, SCIP_LOCKTYPE from sys import version_info @@ -274,3 +277,12 @@ def create_model(): #assert "consdelvars" in calls #assert "consprint" in calls assert "consgetnvars" in calls + + +def test_lotsizing_lazy_example(): + # The example solves the same instance with the plain MIP formulation and with + # (l,S) inequalities added lazily by a needscons=False constraint handler, and + # asserts that both give the same objective. It regressed silently once presolve + # started aggregating away variables the handler had not locked (#1228). + example = os.path.join(os.path.dirname(__file__), "..", "examples", "finished", "lotsizing_lazy.py") + runpy.run_path(example, run_name="__main__")