diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 32d274b1..246f9e4a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -45,11 +45,24 @@ jobs: run: pip install gcovr - name: Extract Coverage run: gcovr -r . -e ./test --object-directory=./build/Debug/CMakeFiles --exclude-unreachable-branches --exclude-throw-branches -b --sonarqube coverage.xml --xml cobertura.xml - - uses: codecov/codecov-action@v3 + - name: Upload Coverage + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./boosttest.xml,./coverage.xml,./cobertura.xml - verbose: true # optional (default = false) + files: ./coverage.xml,./cobertura.xml + disable_search: true + verbose: true + fail_ci_if_error: true + # Codecov treats JUnit test results as a separate report type, so they need their own upload + - name: Upload Test Results + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v7 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./boosttest.xml + disable_search: true + report_type: test_results + verbose: true fail_ci_if_error: true test_release_win: strategy: diff --git a/changelog.md b/changelog.md index f6446801..dcf439ab 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - [Doc:Unreleased] +### Added + +- [PR44](https://github.com/scipopt/SCIPpp/pull/44), [Issue29](https://github.com/scipopt/SCIPpp/issues/29) Support + of ObjSCIP without accessing the raw SCIP object: + - `Model::include` includes plugins derived from `ObjBranchrule`, `ObjConshdlr`, `ObjCutsel`, + `ObjDisp`, `ObjEventhdlr`, `ObjHeur`, `ObjIISfinder`, `ObjNodesel`, + `ObjPresol`, `ObjPricer`, `ObjProp`, `ObjReader`, `ObjRelax`, and `ObjSepa`, + - `Model::includeBenders` and `Model::includeBenderscut` include Benders' decompositions and cuts derived from + `ObjBenders` and `ObjBenderscut`, + - `Model::setMessagehdlr` installs message handlers derived from `ObjMessagehdlr`, + - a new constructor of `Model` takes problem data derived from `ObjProbData`, and + - a new overload of `Model::addVar` takes variable data derived from `ObjVardata`. + ### Changed - [PR45](https://github.com/scipopt/SCIPpp/pull/45) Update to SCIP 10.1.0. diff --git a/include/scippp/model.hpp b/include/scippp/model.hpp index 3be8495b..0aac2ff9 100644 --- a/include/scippp/model.hpp +++ b/include/scippp/model.hpp @@ -4,10 +4,13 @@ #include #include #include +#include +#include #include #include #include #include +#include #include #include "scippp/constant_coefficient.hpp" @@ -52,6 +55,83 @@ class Model { //! Wrapper for every call to %SCIP's %C %API. std::function m_scipCallWrapper; + /** + * Constructs a plugin and includes it, %SCIP takes ownership on success. + * + * @tparam Plugin Type of the plugin, derived from \p Base. + * @tparam Base objscip base class of the plugin. + * @tparam Args Types of the additional constructor arguments. + * @param scipInclude %SCIP's include function for \p Base, e.g., SCIPincludeObjEventhdlr. + * @param args passed to the constructor of \p Plugin after the %SCIP data structure. + * @return Non-owning pointer to the plugin, or \c nullptr if including failed. + */ + template + Plugin* constructAndInclude(SCIP_RETCODE (*scipInclude)(SCIP*, Base*, SCIP_Bool), Args&&... args) const + { + static_assert(std::is_base_of_v, "Plugin must derive from the objscip base class"); + auto plugin { std::make_unique(m_scip, std::forward(args)...) }; + const auto RETCODE { scipInclude(m_scip, plugin.get(), TRUE) }; + // SCIP owns the plugin only on success, otherwise it is deleted when leaving this method. + Plugin* result { RETCODE == SCIP_OKAY ? plugin.release() : nullptr }; + m_scipCallWrapper(RETCODE); + return result; + } + + /** + * Maps an objscip base class to %SCIP's include function for it, so that include() can select it by overload + * resolution on a pointer to the plugin. A plugin deriving from several base classes is rejected as ambiguous. + * @{ + */ + static auto includeFunction(scip::ObjBranchrule*) { return &SCIPincludeObjBranchrule; } + static auto includeFunction(scip::ObjConshdlr*) { return &SCIPincludeObjConshdlr; } + static auto includeFunction(scip::ObjCutsel*) { return &SCIPincludeObjCutsel; } + static auto includeFunction(scip::ObjDisp*) { return &SCIPincludeObjDisp; } + static auto includeFunction(scip::ObjEventhdlr*) { return &SCIPincludeObjEventhdlr; } + static auto includeFunction(scip::ObjHeur*) { return &SCIPincludeObjHeur; } + static auto includeFunction(scip::ObjIISfinder*) { return &SCIPincludeObjIISfinder; } + static auto includeFunction(scip::ObjNodesel*) { return &SCIPincludeObjNodesel; } + static auto includeFunction(scip::ObjPresol*) { return &SCIPincludeObjPresol; } + static auto includeFunction(scip::ObjPricer*) { return &SCIPincludeObjPricer; } + static auto includeFunction(scip::ObjProp*) { return &SCIPincludeObjProp; } + static auto includeFunction(scip::ObjReader*) { return &SCIPincludeObjReader; } + static auto includeFunction(scip::ObjRelax*) { return &SCIPincludeObjRelax; } + static auto includeFunction(scip::ObjSepa*) { return &SCIPincludeObjSepa; } + static auto includeFunction(scip::ObjBenders*) = delete; // use includeBenders() + static auto includeFunction(scip::ObjBenderscut*) = delete; // use includeBenderscut() + /** @} */ + + /** + * Adds a created variable to the problem and stores it. + * + * @param var created variable, %SCIP's reference to it is kept by this model. + * @return Reference to the stored variable. + */ + Var& addCreatedVar(SCIP_VAR* var); + + /** + * Activates an included variable pricer, so that it is used when solving the model. + * + * @param pricer to activate. + */ + void activatePricer(const scip::ObjPricer& pricer) const; + + /** + * Includes the default Benders' cuts for an included Benders' decomposition and activates it together with the + * constraint handlers enforcing it, so that it is used when solving the model. + * + * @param benders Benders' decomposition to activate. + * @param nSubproblems number of subproblems of the Benders' decomposition. + */ + void activateBenders(const scip::ObjBenders& benders, int nSubproblems) const; + + /** + * Prepares the %SCIP data structure without creating a problem in it. + * + * @param scip to use. If \c nullptr, a new %SCIP data structure will be created. + * @param includeDefaultPlugins if \c true, the default plugins are added to \p scip. + */ + Model(SCIP* scip, bool includeDefaultPlugins); + public: /** * Creates an empty problem and sets the optimization goal to Sense::MINIMIZE. @@ -65,6 +145,21 @@ class Model { */ explicit Model(const std::string& name, SCIP* scip = nullptr, bool includeDefaultPlugins = true); + /** + * Creates an empty problem with custom problem data and sets the optimization goal to Sense::MINIMIZE. + * @since 1.5.0 + * @param name for the problem. + * @param probData to store in the problem, must not be \c nullptr. %SCIP takes ownership and deletes it when the + * problem is freed. + * @param scip to create the problem in. If \c nullptr, a new %SCIP data structure will be created. + * @param includeDefaultPlugins if \c true, the default plugins are added to \p scip. + */ + Model( + const std::string& name, + std::unique_ptr probData, + SCIP* scip = nullptr, + bool includeDefaultPlugins = true); + /** * Releases the variables and constraints. * @since 1.0.0 @@ -102,6 +197,26 @@ class Model { std::optional lb = 0.0, std::optional ub = 1.0); + /** + * Adds a variable with custom variable data to the model. + * @since 1.5.0 + * @param name of the variable when the model is written. + * @param vardata to store in the variable, must not be \c nullptr. %SCIP takes ownership and deletes it when the + * variable is freed. + * @param coeff Coefficient in the objective function. + * @param varType variable type. + * @param lb lower bound. \c std::nullopt is interpreted as -infinity. + * @param ub upper bound. \c std::nullopt is interpreted as infinity. + * @return Reference to the newly created variable. + */ + Var& addVar( + const std::string& name, + std::unique_ptr vardata, + SCIP_Real coeff = 0.0, + VarType varType = VarType::CONTINUOUS, + std::optional lb = 0.0, + std::optional ub = 1.0); + /** * Adds multiple variables to the model. * @since 1.0.0 @@ -356,5 +471,109 @@ class Model { * @return The generated %IIS. */ [[nodiscard]] IIS generateIIS() const; + + /** + * Installs a custom message handler, i.e., all info, warning, and dialog messages of %SCIP are passed to it. + * @since 1.5.0 + * @param handler to install, must not be \c nullptr. %SCIP takes ownership and deletes it when it is no longer + * used. + * @attention Must be called before solve(). Error messages are not passed to \p handler as %SCIP prints them via a + * global error printing function. + */ + void setMessagehdlr(std::unique_ptr handler) const; + + /** + * Includes a custom plugin, e.g., an event handler, a primal heuristic, or a separator. + * + * The plugin is constructed by this method as the objscip base classes require the %SCIP data structure in their + * constructors. %SCIP takes ownership and deletes it when the model is destructed. The kind of the plugin is + * determined by its objscip base class. Variable pricers are activated after including them. For Benders' + * decompositions and their cuts, use includeBenders() and includeBenderscut(). + * + * @since 1.5.0 + * @tparam Plugin Type of the plugin, derived from exactly one of the supported objscip base classes, see readme. + * @tparam Args Types of the additional constructor arguments. + * @param args passed to the constructor of \p Plugin after the %SCIP data structure. + * @return Non-owning pointer to the plugin, or \c nullptr if including failed. + * @attention Must be called before solve(). + * @note SCIP++ cannot create constraints of a custom constraint handler. Thus, pass \c needscons = \c FALSE to the + * constructor of scip::ObjConshdlr, so that the handler is called without constraints, and lock the + * variables in \c scip_lock, which is then called with \c cons = \c nullptr. + * @note Constraints added via addConstr() are not modifiable, i.e., priced variables cannot be added to them. + */ + template + Plugin* include(Args&&... args) const + { + // overload resolution picks the include function of the base class, see includeFunction + auto* plugin { constructAndInclude( + includeFunction(static_cast(nullptr)), std::forward(args)...) }; + if constexpr (std::is_base_of_v) { + if (plugin != nullptr) { + activatePricer(*plugin); + } + } + return plugin; + } + + /** + * Includes and activates a custom Benders' decomposition together with %SCIP's default Benders' cuts. + * + * The Benders' decomposition is constructed by this method as scip::ObjBenders requires the %SCIP data structure + * in its constructor. %SCIP takes ownership and deletes it when the model is destructed. + * + * Activating the Benders' decomposition also activates the constraint handlers \c benders and \c benderslp, which + * enforce it. If \p Benders is not cloneable, copying Benders' decompositions to sub-SCIPs is disabled via the + * parameter \c benders/copybenders, as %SCIP cannot copy it. + * + * @since 1.5.0 + * @tparam Benders Type of the Benders' decomposition, derived from scip::ObjBenders. + * @tparam Args Types of the additional constructor arguments. + * @param nSubproblems number of subproblems of the Benders' decomposition. + * @param args passed to the constructor of \p Benders after the %SCIP data structure. + * @return Non-owning pointer to the Benders' decomposition, or \c nullptr if including failed. + * @attention Must be called before solve(). The priority of \p Benders has to be positive, as %SCIP expects + * active Benders' decompositions to have higher priorities than the inactive default one. + * @attention scip::ObjBenders always provides the callbacks to solve subproblems, so %SCIP does not solve them + * itself. Hence, \p Benders has to implement \c scip_solvesubconvex or \c scip_solvesub. + * @note The default Benders' cuts can be disabled via the parameters + * benders/\/benderscut/\/enabled. + */ + template + Benders* includeBenders(int nSubproblems, Args&&... args) const + { + auto* benders { constructAndInclude(&SCIPincludeObjBenders, std::forward(args)...) }; + if (benders != nullptr) { + activateBenders(*benders, nSubproblems); + } + return benders; + } + + /** + * Includes a custom Benders' decomposition cut for a Benders' decomposition. + * + * The cut is constructed by this method as scip::ObjBenderscut requires the %SCIP data structure in its + * constructor. %SCIP takes ownership and deletes it when the model is destructed. + * + * @since 1.5.0 + * @tparam Benderscut Type of the cut, derived from scip::ObjBenderscut. + * @tparam Args Types of the additional constructor arguments. + * @param benders Benders' decomposition to generate the cuts for, see includeBenders(). + * @param args passed to the constructor of \p Benderscut after the %SCIP data structure. + * @return Non-owning pointer to the cut, or \c nullptr if including failed. + * @attention Must be called before solve(). + */ + template + Benderscut* includeBenderscut(scip::ObjBenders& benders, Args&&... args) const + { + // like constructAndInclude, but the include function additionally requires the Benders' decomposition + static_assert( + std::is_base_of_v, "Benderscut must derive from scip::ObjBenderscut"); + auto cut { std::make_unique(m_scip, std::forward(args)...) }; + const auto RETCODE { SCIPincludeObjBenderscut(m_scip, &benders, cut.get(), TRUE) }; + // SCIP owns the cut only on success, otherwise it is deleted when leaving this method. + Benderscut* result { RETCODE == SCIP_OKAY ? cut.release() : nullptr }; + m_scipCallWrapper(RETCODE); + return result; + } }; } diff --git a/readme.md b/readme.md index 8579ce33..96d55e09 100644 --- a/readme.md +++ b/readme.md @@ -155,6 +155,52 @@ model.solve(); auto pb { model.getSolvingStatistic(statistics::PRIMALBOUND) }; ``` +### Custom Plugins (ObjSCIP) + +Plugins derived from SCIP's [ObjSCIP](https://www.scipopt.org/doc/html/OBJ.php) base classes are included via +`Model::include`. The plugin is constructed by SCIP++, because the base classes need the SCIP data structure in their +constructors. Its constructor gets the `SCIP*` as first argument, followed by the arguments passed to `include`. SCIP +takes ownership of the plugin, `include` returns a non-owning pointer to it. Plugins have to be included before solving. + +```cpp +class MyHeuristic : public scip::ObjHeur { +public: + MyHeuristic(SCIP* scip, const std::vector& vars) + : ObjHeur(scip, "myheur", "my heuristic", 'x', 1000, 1, 0, -1, SCIP_HEURTIMING_AFTERNODE, FALSE) + , m_vars(vars) + { + } + SCIP_DECL_HEUREXEC(scip_exec) override; +private: + std::vector m_vars; +}; + +auto vars = model.addVars("x_", 42); +auto* heur = model.include(vars); +model.solve(); +``` + +This works the same way for plugins derived from `ObjBranchrule`, `ObjConshdlr`, `ObjCutsel`, `ObjDisp`, +`ObjEventhdlr`, `ObjHeur`, `ObjIISfinder`, `ObjNodesel`, `ObjPresol`, `ObjPricer`, `ObjProp`, `ObjReader`, `ObjRelax`, +and `ObjSepa`, with the following exceptions: + +* `ObjConshdlr`: SCIP++ cannot create constraints of a custom constraint handler. Pass `needscons = FALSE` to the + constructor, so that the handler is called without constraints, and lock the variables in `scip_lock`, which is then + called with `cons = nullptr`. +* `ObjPricer`: The pricer is activated after including it. Constraints added via `Model::addConstr` are not + modifiable, i.e., priced variables cannot be added to them. +* `ObjBenders`: Use `model.includeBenders(nSubproblems, args...)`. It activates the decomposition together + with SCIP's default Benders' cuts. The priority has to be positive, and `scip_solvesubconvex` or `scip_solvesub` has + to be implemented, as SCIP does not solve the subproblems itself. +* `ObjBenderscut`: Use `model.includeBenderscut(*benders, args...)` with the pointer returned by + `includeBenders`. +* `ObjMessagehdlr`: Is not constructed by SCIP++, install it via + `model.setMessagehdlr(std::make_unique())`. Error messages are not passed to it. +* `ObjProbData`: Pass it to the constructor, `Model model("name", std::make_unique())`. +* `ObjVardata`: Pass it when adding a variable, `model.addVar("x", std::make_unique(), 1.0)`. +* `ObjTable`: Is not supported, as SCIP does not export it from its shared library, see + [SCIP issue 222](https://github.com/scipopt/scip/issues/222). + ### Features Not Yet Supported For features not yet supported by SCIP++, one can access the underlying raw SCIP object via diff --git a/source/model.cpp b/source/model.cpp index 5f29b308..b516becc 100644 --- a/source/model.cpp +++ b/source/model.cpp @@ -13,22 +13,29 @@ Var& Model::addVar( std::optional lb, std::optional ub) { + const auto LB { lb.value_or(-SCIPinfinity(m_scip)) }; + const auto UB { ub.value_or(SCIPinfinity(m_scip)) }; SCIP_VAR* var { nullptr }; m_scipCallWrapper(SCIPcreateVarBasic( m_scip, /* SCIP environment */ &var, /* reference to the variable */ name.c_str(), /* name of the variable */ - lb != std::nullopt ? lb.value() : -SCIPinfinity(m_scip), /* lower bound of the variable */ - ub != std::nullopt ? ub.value() : SCIPinfinity(m_scip), /* upper bound of the variable */ + LB, /* lower bound of the variable */ + UB, /* upper bound of the variable */ coeff, /* obj. coefficient. */ - static_cast(varType) /* variable is binary */ + static_cast(varType) /* variable type */ )); + return addCreatedVar(var); +} + +Var& Model::addCreatedVar(SCIP_VAR* var) +{ m_scipCallWrapper(SCIPaddVar(m_scip, var)); m_vars.emplace_back(Var { var }); return m_vars.back(); } -Model::Model(const std::string& name, SCIP* scip, bool withDefaultPlugins) +Model::Model(SCIP* scip, bool withDefaultPlugins) { m_scipCallWrapper = [this](SCIP_Retcode r) { m_lastReturnCode = r; }; if (scip) { @@ -40,6 +47,11 @@ Model::Model(const std::string& name, SCIP* scip, bool withDefaultPlugins) if (withDefaultPlugins) { m_scipCallWrapper(SCIPincludeDefaultPlugins(m_scip)); } +} + +Model::Model(const std::string& name, SCIP* scip, bool withDefaultPlugins) + : Model(scip, withDefaultPlugins) +{ m_scipCallWrapper(SCIPcreateProbBasic(m_scip, name.c_str())); } diff --git a/source/model_objscip.cpp b/source/model_objscip.cpp new file mode 100644 index 00000000..b4620829 --- /dev/null +++ b/source/model_objscip.cpp @@ -0,0 +1,90 @@ +#include "scippp/model.hpp" + +#include + +namespace scippp { + +Model::Model( + const std::string& name, + std::unique_ptr probData, + SCIP* scip, + bool withDefaultPlugins) + : Model(scip, withDefaultPlugins) +{ + assert(probData); // GCOVR_EXCL_LINE + const auto RETCODE { SCIPcreateObjProb(m_scip, name.c_str(), probData.get(), TRUE) }; + // SCIP owns the problem data only on success, otherwise it is deleted when leaving this constructor. + // SCIPcreateObjProb only fails in an invalid stage, when out of memory, or when freeing a previous problem fails, + // which a test cannot provoke without leaving the SCIP object in a corrupted state. + if (RETCODE == SCIP_OKAY) { // GCOVR_EXCL_BR_LINE + probData.release(); + } + m_scipCallWrapper(RETCODE); +} + +Var& Model::addVar( + const std::string& name, + std::unique_ptr vardata, + SCIP_Real coeff, + VarType varType, + std::optional lb, + std::optional ub) +{ + assert(vardata); // GCOVR_EXCL_LINE + const auto LB { lb.value_or(-SCIPinfinity(m_scip)) }; + const auto UB { ub.value_or(SCIPinfinity(m_scip)) }; + SCIP_VAR* var { nullptr }; + const auto RETCODE { SCIPcreateObjVar( + m_scip, /* SCIP environment */ + &var, /* reference to the variable */ + name.c_str(), /* name of the variable */ + LB, /* lower bound of the variable */ + UB, /* upper bound of the variable */ + coeff, /* obj. coefficient. */ + static_cast(varType), /* variable type */ + TRUE, /* initial */ + FALSE, /* removable */ + vardata.get(), /* variable data */ + TRUE /* delete the variable data when the variable is freed */ + ) }; + // SCIP owns the variable data only on success, otherwise it is deleted when leaving this method. + if (RETCODE == SCIP_OKAY) { + vardata.release(); + } + m_scipCallWrapper(RETCODE); + return addCreatedVar(var); +} + +void Model::setMessagehdlr(std::unique_ptr handler) const +{ + assert(handler); // GCOVR_EXCL_LINE + SCIP_MESSAGEHDLR* messagehdlr { nullptr }; + // SCIP owns the handler object from now on, it even deletes it if the creation fails + m_scipCallWrapper(SCIPcreateObjMessagehdlr(&messagehdlr, handler.release(), TRUE)); + const auto RETCODE { SCIPsetMessagehdlr(m_scip, messagehdlr) }; + // SCIPsetMessagehdlr captures the message handler on success, so we always release our reference. Releasing before + // passing RETCODE to the call wrapper avoids a leak if the wrapper throws, and keeps RETCODE as the last return code. + m_scipCallWrapper(SCIPmessagehdlrRelease(&messagehdlr)); + m_scipCallWrapper(RETCODE); +} + +void Model::activatePricer(const scip::ObjPricer& pricer) const +{ + m_scipCallWrapper(SCIPactivatePricer(m_scip, SCIPfindPricer(m_scip, pricer.scip_name_))); +} + +void Model::activateBenders(const scip::ObjBenders& benders, int nSubproblems) const +{ + auto* scipBenders { SCIPfindBenders(m_scip, benders.scip_name_) }; + m_scipCallWrapper(SCIPincludeBendersDefaultCuts(m_scip, scipBenders)); + m_scipCallWrapper(SCIPactivateBenders(m_scip, scipBenders, nSubproblems)); + // otherwise the constraint handlers do not enforce the Benders' decomposition + m_scipCallWrapper(SCIPsetBoolParam(m_scip, "constraints/benders/active", TRUE)); + m_scipCallWrapper(SCIPsetBoolParam(m_scip, "constraints/benderslp/active", TRUE)); + if (!benders.iscloneable()) { + // SCIP crashes when copying the model to a sub-SCIP, e.g., in a heuristic, as it expects the copy to succeed + m_scipCallWrapper(SCIPsetBoolParam(m_scip, "benders/copybenders", FALSE)); + } +} + +} diff --git a/test/test_objscip.cpp b/test/test_objscip.cpp index f4085faf..4dea8f89 100644 --- a/test/test_objscip.cpp +++ b/test/test_objscip.cpp @@ -1,7 +1,17 @@ +#include +#include +#include #include +#include +#include +#include +#include +#include +#include #include "scippp/model.hpp" -#include +#include "scippp/parameters.hpp" +#include "scippp/solving_statistics.hpp" using namespace scippp; using namespace std; @@ -9,23 +19,52 @@ using namespace std; BOOST_AUTO_TEST_SUITE(ObjSCIP) /** - * We check whether SCIP++ works with ObjSCIP + * Creates a path to a not yet existing file with the given extension in the temporary directory. */ -class MyEventHandler : public scip::ObjEventhdlr { +filesystem::path uniqueTempPath(const string& extension) +{ + return filesystem::temp_directory_path() + / boost::filesystem::unique_path("%%%%-%%%%-%%%%-%%%%." + extension).string(); +} + +/** + * Reads the whole file and removes it. + */ +string readAndRemove(const filesystem::path& path) +{ + ifstream file(path); + ostringstream content; + content << file.rdbuf(); + file.close(); + filesystem::remove(path); + return content.str(); +} + +/** + * Counts how often a new best solution is found. + */ +class BestSolCounter : public scip::ObjEventhdlr { public: - MyEventHandler(SCIP* scip) - : scip::ObjEventhdlr(scip, "NAME", "DESC") + int nBestSols { 0 }; + + explicit BestSolCounter(SCIP* scip) + : scip::ObjEventhdlr(scip, "bestsolcounter", "counts new best solutions") { } SCIP_DECL_EVENTINIT(scip_init) override { - clog << "MyEventHandler: Here I am" << endl; - return SCIP_OKAY; + return SCIPcatchEvent(scip, SCIP_EVENTTYPE_BESTSOLFOUND, eventhdlr, nullptr, nullptr); + } + SCIP_DECL_EVENTEXIT(scip_exit) + override + { + return SCIPdropEvent(scip, SCIP_EVENTTYPE_BESTSOLFOUND, eventhdlr, nullptr, -1); } SCIP_DECL_EVENTEXEC(scip_exec) override { + ++nBestSols; return SCIP_OKAY; } }; @@ -37,10 +76,1006 @@ BOOST_AUTO_TEST_CASE(UseEventHandler) auto x2 = model.addVar("x_2", 1); model.addConstr(x1 + x2 >= 1, "capacity"); model.addConstr(x1 == x2, "equal"); - SCIPincludeObjEventhdlr(model.scip(), new MyEventHandler(model.scip()), false); + const auto* counter { model.include() }; + BOOST_REQUIRE(counter != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); model.setObjsense(Sense::MINIMIZE); model.solve(); BOOST_TEST(model.getNSols() > 0); + BOOST_TEST(counter->nBestSols > 0); +} + +BOOST_AUTO_TEST_CASE(IncludeEventHandlerTwice) +{ + Model model("Simple"); + BOOST_TEST(model.include() != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + // SCIP rejects a second event handler with the same name, the handler is then deleted by SCIP++ + BOOST_TEST(model.include() == nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_INVALIDDATA); +} + +/** + * Lazily enforces a non-negative variable to be zero, without any constraint. + */ +class ZeroVarConshdlr : public scip::ObjConshdlr { + SCIP_VAR* m_var; + + [[nodiscard]] bool isViolated(SCIP* scip, SCIP_SOL* sol) const + { + return SCIPisFeasPositive(scip, SCIPgetSolVal(scip, sol, m_var)); + } + + SCIP_RETCODE enforce(SCIP* scip, SCIP_RESULT* result) const + { + *result = SCIP_FEASIBLE; + if (isViolated(scip, nullptr)) { + SCIP_Bool infeasible { FALSE }; + SCIP_Bool tightened { FALSE }; + SCIP_CALL(SCIPtightenVarUb(scip, SCIPvarGetTransVar(m_var), 0.0, FALSE, &infeasible, &tightened)); + *result = infeasible ? SCIP_CUTOFF : SCIP_REDUCEDDOM; + } + return SCIP_OKAY; + } + +public: + ZeroVarConshdlr(SCIP* scip, SCIP_VAR* var) + : scip::ObjConshdlr( + scip, + "zerovar", + "enforces a variable to be zero", + 0, // sepapriority + -1, // enfopriority, negative to be called for integral solutions only + -1, // checkpriority + -1, // sepafreq + -1, // propfreq + -1, // eagerfreq + 0, // maxprerounds + FALSE, // delaysepa + FALSE, // delayprop + FALSE, // needscons + SCIP_PROPTIMING_BEFORELP, + SCIP_PRESOLTIMING_FAST) + , m_var(var) + { + } + SCIP_DECL_CONSTRANS(scip_trans) + override + { + // there are no constraints of this handler to transform + return SCIP_INVALIDCALL; + } + SCIP_DECL_CONSENFOLP(scip_enfolp) + override + { + return enforce(scip, result); + } + SCIP_DECL_CONSENFOPS(scip_enfops) + override + { + return enforce(scip, result); + } + SCIP_DECL_CONSCHECK(scip_check) + override + { + *result = isViolated(scip, sol) ? SCIP_INFEASIBLE : SCIP_FEASIBLE; + return SCIP_OKAY; + } + SCIP_DECL_CONSLOCK(scip_lock) + override + { + // called without constraint as this handler does not need constraints, increasing the variable may violate it + return SCIPaddVarLocksType(scip, SCIPvarGetTransVar(m_var), locktype, nlocksneg, nlockspos); + } +}; + +BOOST_AUTO_TEST_CASE(UseConstraintHandler) +{ + Model model("Simple"); + auto x1 = model.addVar("x_1", 1, VarType::BINARY); + auto x2 = model.addVar("x_2", 1, VarType::BINARY); + model.setObjsense(Sense::MAXIMIZE); + BOOST_TEST(model.include(x1.getVar()) != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(model.getSolvingStatistic(statistics::PRIMALBOUND) == 1); + BOOST_TEST(x1.getSolValAsInt(model.getBestSol()) == 0); + BOOST_TEST(x2.getSolValAsInt(model.getBestSol()) == 1); +} + +/** + * Proposes the solution where a given binary variable is one and all others are zero, before presolving. + */ +class OneVarHeur : public scip::ObjHeur { + SCIP_VAR* m_var; + +public: + int nFound { 0 }; + + OneVarHeur(SCIP* scip, SCIP_VAR* var) + : scip::ObjHeur( + scip, + "onevar", + "proposes a solution where a given binary variable is one", + 'O', // dispchar + 20000, // priority, higher than the one of the trivial heuristic to be called first + 1, // freq + 0, // freqofs + -1, // maxdepth + SCIP_HEURTIMING_BEFOREPRESOL, + FALSE) // usessubscip + , m_var(var) + { + } + SCIP_DECL_HEUREXEC(scip_exec) + override + { + SCIP_SOL* sol { nullptr }; + SCIP_CALL(SCIPcreateSol(scip, &sol, heur)); + SCIP_CALL(SCIPsetSolVal(scip, sol, SCIPvarGetTransVar(m_var), 1.0)); + SCIP_Bool stored { FALSE }; + SCIP_CALL(SCIPtrySolFree(scip, &sol, FALSE, FALSE, TRUE, TRUE, TRUE, &stored)); + if (stored) { + ++nFound; + *result = SCIP_FOUNDSOL; + } else { + *result = SCIP_DIDNOTFIND; + } + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UseHeuristic) +{ + Model model("Simple"); + auto x1 = model.addVar("x_1", 1, VarType::BINARY); + auto x2 = model.addVar("x_2", 1, VarType::BINARY); + model.addConstr(x1 + x2 <= 1, "capacity"); + model.setObjsense(Sense::MAXIMIZE); + const auto* heur { model.include(x1.getVar()) }; + BOOST_REQUIRE(heur != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + // the heuristic is called first, so its solution is stored as there is no other solution yet + BOOST_TEST(heur->nFound == 1); + BOOST_TEST(model.getSolvingStatistic(statistics::PRIMALBOUND) == 1); +} + +/** + * Fixes a variable to zero, e.g., based on problem-specific knowledge. + */ +class ZeroVarPresol : public scip::ObjPresol { + SCIP_VAR* m_var; + +public: + ZeroVarPresol(SCIP* scip, SCIP_VAR* var) + : scip::ObjPresol( + scip, + "zerovar", + "fixes a variable to zero", + 10000000, // priority, higher than the ones of the default presolvers to be called first + -1, // maxrounds + SCIP_PRESOLTIMING_FAST) + , m_var(var) + { + } + SCIP_DECL_PRESOLEXEC(scip_exec) + override + { + SCIP_Bool infeasible { FALSE }; + SCIP_Bool fixed { FALSE }; + SCIP_CALL(SCIPfixVar(scip, SCIPvarGetTransVar(m_var), 0.0, &infeasible, &fixed)); + if (infeasible) { + *result = SCIP_CUTOFF; + } else if (fixed) { + ++(*nfixedvars); + *result = SCIP_SUCCESS; + } else { + *result = SCIP_DIDNOTFIND; + } + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UsePresolver) +{ + Model model("Simple"); + auto x1 = model.addVar("x_1", 2, VarType::BINARY); + auto x2 = model.addVar("x_2", 1, VarType::BINARY); + model.addConstr(x1 + x2 <= 1, "capacity"); + model.setObjsense(Sense::MAXIMIZE); + BOOST_TEST(model.include(x1.getVar()) != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + // without the presolver, x_1 = 1 would be optimal with objective 2 + BOOST_TEST(model.getSolvingStatistic(statistics::PRIMALBOUND) == 1); + BOOST_TEST(x1.getSolValAsInt(model.getBestSol()) == 0); + BOOST_TEST(x2.getSolValAsInt(model.getBestSol()) == 1); +} + +/** + * Counts how often it is called for domain propagation. + */ +class CountingProp : public scip::ObjProp { +public: + int nCalls { 0 }; + + explicit CountingProp(SCIP* scip) + : scip::ObjProp( + scip, + "counting", + "counts how often it is called", + 0, // priority + 1, // freq + FALSE, // delay + SCIP_PROPTIMING_BEFORELP, + 0, // presolpriority + -1, // presolmaxrounds + SCIP_PRESOLTIMING_NONE) + { + } + SCIP_DECL_PROPEXEC(scip_exec) + override + { + ++nCalls; + *result = SCIP_DIDNOTFIND; + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UsePropagator) +{ + Model model("Simple"); + auto x1 = model.addVar("x_1", 2, VarType::BINARY); + auto x2 = model.addVar("x_2", 1, VarType::BINARY); + model.addConstr(x1 + x2 <= 1, "capacity"); + model.setObjsense(Sense::MAXIMIZE); + // otherwise presolving solves the problem and no node is processed + model.setParam(params::PRESOLVING::MAXROUNDS, 0); + const auto* prop { model.include() }; + BOOST_REQUIRE(prop != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(prop->nCalls > 0); +} + +/** + * Adds max 1.1 x_1 + x_2 s.t. 2 x_1 + 3 x_2 <= 4 with binary variables, whose LP relaxation has the fractional + * solution x_1 = 1, x_2 = 2/3, while x_1 = 1, x_2 = 0 is optimal. + */ +array addFractionalProblem(Model& model) +{ + auto x1 = model.addVar("x_1", 1.1, VarType::BINARY); + auto x2 = model.addVar("x_2", 1, VarType::BINARY); + model.addConstr(2 * x1 + 3 * x2 <= 4, "capacity"); + model.setObjsense(Sense::MAXIMIZE); + // otherwise presolving or propagation solve the problem before the LP relaxation is solved + model.setParam(params::PRESOLVING::MAXROUNDS, 0); + model.setParam(params::PROPAGATING::MAXROUNDSROOT, 0); + return { x1, x2 }; +} + +/** + * Separates the cut x_1 + x_2 <= 1 at the root, which is valid for binary variables with 2 x_1 + 3 x_2 <= 4. + */ +class CapacitySepa : public scip::ObjSepa { + array m_vars; + +public: + int nCuts { 0 }; + + CapacitySepa(SCIP* scip, SCIP_VAR* x1, SCIP_VAR* x2) + : scip::ObjSepa( + scip, + "capacity", + "separates x_1 + x_2 <= 1", + 1000000, // priority, higher than the ones of the default separators to be called first + 0, // freq + 1.0, // maxbounddist + FALSE, // usessubscip + FALSE) // delay + , m_vars { x1, x2 } + { + } + SCIP_DECL_SEPAEXECLP(scip_execlp) + override + { + *result = SCIP_DIDNOTFIND; + SCIP_ROW* row { nullptr }; + SCIP_CALL(SCIPcreateEmptyRowSepa(scip, &row, sepa, "capacity", -SCIPinfinity(scip), 1.0, FALSE, FALSE, TRUE)); + for (auto* var : m_vars) { + SCIP_CALL(SCIPaddVarToRow(scip, row, SCIPvarGetTransVar(var), 1.0)); + } + if (SCIPisCutEfficacious(scip, nullptr, row)) { + SCIP_Bool infeasible { FALSE }; + SCIP_CALL(SCIPaddRow(scip, row, FALSE, &infeasible)); + ++nCuts; + *result = infeasible ? SCIP_CUTOFF : SCIP_SEPARATED; + } + return SCIPreleaseRow(scip, &row); + } +}; + +BOOST_AUTO_TEST_CASE(UseSeparator) +{ + Model model("Simple"); + auto [x1, x2] = addFractionalProblem(model); + const auto* sepa { model.include(x1.getVar(), x2.getVar()) }; + BOOST_REQUIRE(sepa != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(sepa->nCuts > 0); +} + +/** + * Selects all cuts, up to the maximal number of cuts to select. + */ +class AllCutsel : public scip::ObjCutsel { +public: + int nCalls { 0 }; + + explicit AllCutsel(SCIP* scip) + : scip::ObjCutsel( + scip, + "all", + "selects all cuts", + 1000000) // priority, higher than the ones of the default cut selectors to be used + { + } + SCIP_DECL_CUTSELSELECT(scip_select) + override + { + ++nCalls; + *nselectedcuts = min(ncuts, maxnselectedcuts); + *result = SCIP_SUCCESS; + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UseCutSelector) +{ + Model model("Simple"); + auto [x1, x2] = addFractionalProblem(model); + BOOST_REQUIRE(model.include(x1.getVar(), x2.getVar()) != nullptr); + const auto* cutsel { model.include() }; + BOOST_REQUIRE(cutsel != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(cutsel->nCalls > 0); +} + +/** + * Branches on the first fractional variable of the LP solution. + */ +class FirstFracBranchrule : public scip::ObjBranchrule { +public: + int nCalls { 0 }; + + explicit FirstFracBranchrule(SCIP* scip) + : scip::ObjBranchrule( + scip, + "firstfrac", + "branches on the first fractional variable", + 1000000, // priority, higher than the ones of the default branching rules to be used + -1, // maxdepth + 1.0) // maxbounddist + { + } + SCIP_DECL_BRANCHEXECLP(scip_execlp) + override + { + ++nCalls; + SCIP_VAR** cands { nullptr }; + int nCands { 0 }; + SCIP_CALL(SCIPgetLPBranchCands(scip, &cands, nullptr, nullptr, &nCands, nullptr, nullptr)); + if (nCands == 0) { + *result = SCIP_DIDNOTRUN; + return SCIP_OKAY; + } + SCIP_CALL(SCIPbranchVar(scip, cands[0], nullptr, nullptr, nullptr)); + *result = SCIP_BRANCHED; + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UseBranchingRule) +{ + Model model("Simple"); + addFractionalProblem(model); + // otherwise the problem is solved at the root by separation or conflict analysis + model.setParam(params::SEPARATING::MAXROUNDSROOT, 0); + model.setParam(params::CONFLICT::ENABLE, false); + const auto* branchrule { model.include() }; + BOOST_REQUIRE(branchrule != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(branchrule->nCalls > 0); +} + +/** + * Selects nodes in depth-first order. + */ +class DepthFirstNodesel : public scip::ObjNodesel { +public: + int nCalls { 0 }; + + explicit DepthFirstNodesel(SCIP* scip) + : scip::ObjNodesel( + scip, + "depthfirst", + "selects nodes in depth-first order", + 1000000, // stdpriority, higher than the ones of the default node selectors to be used + 0) // memsavepriority + { + } + SCIP_DECL_NODESELSELECT(scip_select) + override + { + ++nCalls; + *selnode = SCIPgetPrioChild(scip); + if (*selnode == nullptr) { + *selnode = SCIPgetPrioSibling(scip); + } + if (*selnode == nullptr) { + *selnode = SCIPgetBestLeaf(scip); + } + return SCIP_OKAY; + } + SCIP_DECL_NODESELCOMP(scip_comp) + override + { + // deeper nodes first + return SCIPnodeGetDepth(node2) - SCIPnodeGetDepth(node1); + } +}; + +BOOST_AUTO_TEST_CASE(UseNodeSelector) +{ + Model model("Simple"); + addFractionalProblem(model); + // otherwise the problem is solved at the root by separation or conflict analysis + model.setParam(params::SEPARATING::MAXROUNDSROOT, 0); + model.setParam(params::CONFLICT::ENABLE, false); + const auto* nodesel { model.include() }; + BOOST_REQUIRE(nodesel != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(nodesel->nCalls > 0); +} + +/** + * Counts how often it is called before the LP relaxation is solved. + */ +class CountingRelax : public scip::ObjRelax { +public: + int nCalls { 0 }; + + explicit CountingRelax(SCIP* scip) + : scip::ObjRelax( + scip, + "counting", + "counts how often it is called", + 0, // priority, non-negative to be called before the LP relaxation is solved + 1, // freq + FALSE) // includeslp + { + } + SCIP_DECL_RELAXEXEC(scip_exec) + override + { + ++nCalls; + *result = SCIP_DIDNOTRUN; + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UseRelaxator) +{ + Model model("Simple"); + addFractionalProblem(model); + const auto* relax { model.include() }; + BOOST_REQUIRE(relax != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(relax->nCalls > 0); +} + +/** + * Displays how often it was displayed. + */ +class CountingDisp : public scip::ObjDisp { +public: + int nCalls { 0 }; + + explicit CountingDisp(SCIP* scip) + : scip::ObjDisp( + scip, + "counting", + "displays how often it was displayed", + "calls", // header + 5, // width + 1000000, // priority, higher than the ones of the default display columns to be displayed + 100000, // position, higher than the ones of the default display columns to be the last column + TRUE) // stripline + { + } + SCIP_DECL_DISPOUTPUT(scip_output) + override + { + ++nCalls; + SCIPinfoMessage(scip, file, "%5d", nCalls); + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UseDisplayColumn) +{ + Model model("Simple"); + // nodes have to be processed, otherwise no display line is printed + addFractionalProblem(model); + const auto* disp { model.include() }; + BOOST_REQUIRE(disp != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(disp->nCalls > 0); +} + +/** + * Writes the number of variables and constraints of a problem to files with extension cnt. + */ +class CountReader : public scip::ObjReader { +public: + explicit CountReader(SCIP* scip) + : scip::ObjReader(scip, "count", "writes the number of variables and constraints", "cnt") + { + } + SCIP_DECL_READERWRITE(scip_write) + override + { + SCIPinfoMessage(scip, file, "%d %d\n", nvars, nconss); + *result = SCIP_SUCCESS; + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UseReader) +{ + Model model("Simple"); + auto x1 = model.addVar("x_1", 1); + auto x2 = model.addVar("x_2", 1); + model.addConstr(x1 + x2 >= 1, "capacity"); + BOOST_TEST(model.include() != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + const auto FILE_NAME { uniqueTempPath("cnt") }; + model.writeOrigProblem(filesystem::directory_entry(FILE_NAME)); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + BOOST_TEST(readAndRemove(FILE_NAME) == "2 1\n"); +} + +/** + * Counts how often it is called and leaves the work to the other %IIS finders. + */ +class CountingIISfinder : public scip::ObjIISfinder { +public: + int nCalls { 0 }; + + explicit CountingIISfinder(SCIP* scip) + : scip::ObjIISfinder( + scip, + "counting", + "counts how often it is called", + 1000000) // priority, higher than the ones of the default IIS finders to be called first + { + } + SCIP_DECL_IISFINDEREXEC(scip_exec) + override + { + ++nCalls; + *result = SCIP_DIDNOTRUN; + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UseIISFinder) +{ + Model model("Simple"); + const auto& [x1, x2] = model.addVars<2>("x_"); + model.addConstr(x1 + x2 >= 2, "lower"); + model.addConstr(x1 + x2 <= 1, "upper"); + model.setParam(params::IIS::SILENT, true); + const auto* iisfinder { model.include() }; + BOOST_REQUIRE(iisfinder != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_REQUIRE(model.getStatus() == SCIP_STATUS_INFEASIBLE); + // the default IIS finder still computes the IIS + BOOST_TEST(model.generateIIS().consIds.size() == 2); + BOOST_TEST(iisfinder->nCalls == 1); +} + +/** + * Counts how often it is called for pricing, but never adds a variable. + */ +class CountingPricer : public scip::ObjPricer { +public: + int nCalls { 0 }; + + explicit CountingPricer(SCIP* scip) + : scip::ObjPricer( + scip, + "counting", + "counts how often it is called", + 0, // priority + FALSE) // delay + { + } + SCIP_DECL_PRICERREDCOST(scip_redcost) + override + { + ++nCalls; + *result = SCIP_SUCCESS; + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UsePricer) +{ + Model model("Simple"); + auto x1 = model.addVar("x_1", 1); + auto x2 = model.addVar("x_2", 1); + model.addConstr(x1 + x2 >= 1, "capacity"); + // otherwise presolving solves the problem and no LP is solved + model.setParam(params::PRESOLVING::MAXROUNDS, 0); + const auto* pricer { model.include() }; + BOOST_REQUIRE(pricer != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(pricer->nCalls > 0); + BOOST_TEST(model.getSolvingStatistic(statistics::PRIMALBOUND) == 1); +} + +BOOST_AUTO_TEST_CASE(IncludePricerTwice) +{ + Model model("Simple"); + BOOST_TEST(model.include() != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + // SCIP rejects a second pricer with the same name, the pricer is then deleted by SCIP++ without being activated + BOOST_TEST(model.include() == nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_INVALIDDATA); +} + +/** + * Decomposes min x + y s.t. y >= 2 - c x with binary x and continuous y >= 0 into the master problem min x, which is + * the model, and the subproblem min y s.t. y >= 2 - c x, y >= 0, where x is fixed to its value in the master problem. + * The decomposition computes the optimal value max(0, 2 - c x) of the subproblem itself, so there is no SCIP instance + * for it. + */ +class DemandBenders : public scip::ObjBenders { + SCIP_VAR* m_masterX; + double m_coverage; + +public: + int nSolves { 0 }; + + DemandBenders(SCIP* scip, SCIP_VAR* masterX, double coverage) + : scip::ObjBenders( + scip, + "demand", + "decomposes the demand problem", + 1, // priority, positive to be higher than the one of the inactive default Benders' decomposition + TRUE, // cutlp + TRUE, // cutpseudo + TRUE, // cutrelax + FALSE) // shareauxvars + , m_masterX(masterX) + , m_coverage(coverage) + { + } + SCIP_DECL_BENDERSCREATESUB(scip_createsub) + override + { + SCIP_CALL(SCIPaddBendersSubproblem(scip, benders, nullptr)); + // required as the subproblem is solved by this decomposition + SCIPbendersSetSubproblemType(benders, probnumber, SCIP_BENDERSSUBTYPE_CONVEXCONT); + // the smallest optimal value of the subproblem, which is attained for x = 1 + SCIPbendersUpdateSubproblemLowerbound(benders, probnumber, max(0.0, 2.0 - m_coverage)); + return SCIP_OKAY; + } + SCIP_DECL_BENDERSSOLVESUBCONVEX(scip_solvesubconvex) + override + { + ++nSolves; + *objective = max(0.0, 2.0 - m_coverage * SCIPgetSolVal(scip, sol, m_masterX)); + *result = SCIP_FEASIBLE; + return SCIP_OKAY; + } + SCIP_DECL_BENDERSGETVAR(scip_getvar) + override + { + // there is no SCIP instance for the subproblem, hence no variables to map + *mappedvar = nullptr; + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UseBenders) +{ + Model model("Master"); + auto x = model.addVar("x", 1, VarType::BINARY); + // without coverage, the subproblem has the constant optimal value 2, which is its lower bound, so no cut is needed + const auto* benders { model.includeBenders(1, x.getVar(), 0.0) }; + BOOST_REQUIRE(benders != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(model.getStatus() == SCIP_STATUS_OPTIMAL); + BOOST_TEST(benders->nSolves > 0); + BOOST_TEST(model.getSolvingStatistic(statistics::PRIMALBOUND) == 2.0, boost::test_tools::tolerance(1e-6)); + BOOST_TEST(x.getSolValAsInt(model.getBestSol()) == 0); +} + +BOOST_AUTO_TEST_CASE(IncludeBendersTwice) +{ + Model model("Master"); + auto x = model.addVar("x", 1, VarType::BINARY); + BOOST_TEST(model.includeBenders(1, x.getVar(), 0.0) != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + // SCIP rejects a second Benders' decomposition with the same name, it is then deleted by SCIP++ without being + // activated + BOOST_TEST(model.includeBenders(1, x.getVar(), 0.0) == nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_INVALIDDATA); +} + +/** + * Can be copied to sub-SCIPs. The copy is never solved, so it does not map the master variable. + */ +class CloneableDemandBenders : public DemandBenders { +public: + using DemandBenders::DemandBenders; + SCIP_DECL_OBJCLONEABLECLONE(ObjCloneable* clone) + override + { + return new CloneableDemandBenders(scip, nullptr, 0.0); + } + SCIP_DECL_OBJCLONEABLEISCLONEABLE(iscloneable) + override + { + return TRUE; + } +}; + +BOOST_AUTO_TEST_CASE(CopyBendersOnlyIfCloneable) +{ + SCIP_Bool copyBenders { FALSE }; + Model cloneable("Master"); + auto x = cloneable.addVar("x", 1, VarType::BINARY); + BOOST_REQUIRE(cloneable.includeBenders(1, x.getVar(), 0.0) != nullptr); + BOOST_TEST(SCIPgetBoolParam(cloneable.scip(), "benders/copybenders", ©Benders) == SCIP_OKAY); + BOOST_TEST(copyBenders); + + Model notCloneable("Master"); + auto y = notCloneable.addVar("y", 1, VarType::BINARY); + BOOST_REQUIRE(notCloneable.includeBenders(1, y.getVar(), 0.0) != nullptr); + BOOST_TEST(SCIPgetBoolParam(notCloneable.scip(), "benders/copybenders", ©Benders) == SCIP_OKAY); + BOOST_TEST(!copyBenders); +} + +/** + * Adds the optimality cut theta + c x >= 2 for the subproblem of DemandBenders, where theta is the auxiliary variable + * of the subproblem in the master problem. + */ +class DemandBenderscut : public scip::ObjBenderscut { + SCIP_VAR* m_masterX; + double m_coverage; + +public: + int nCuts { 0 }; + + DemandBenderscut(SCIP* scip, SCIP_VAR* masterX, double coverage) + : scip::ObjBenderscut( + scip, + "demand", + "adds the optimality cut of the demand subproblem", + 1000000, // priority, higher than the ones of the default Benders' cuts to be called first + TRUE) // islpcut + , m_masterX(masterX) + , m_coverage(coverage) + { + } + SCIP_DECL_BENDERSCUTEXEC(scip_exec) + override + { + SCIP_CONS* cons { nullptr }; + SCIP_CALL(SCIPcreateConsBasicLinear(scip, &cons, "demand", 0, nullptr, nullptr, 2.0, SCIPinfinity(scip))); + SCIP_CALL(SCIPaddCoefLinear(scip, cons, SCIPbendersGetAuxiliaryVar(benders, probnumber), 1.0)); + SCIP_CALL(SCIPaddCoefLinear(scip, cons, SCIPvarGetTransVar(m_masterX), m_coverage)); + SCIP_CALL(SCIPaddCons(scip, cons)); + SCIP_CALL(SCIPreleaseCons(scip, &cons)); + ++nCuts; + *result = SCIP_CONSADDED; + return SCIP_OKAY; + } +}; + +BOOST_AUTO_TEST_CASE(UseBendersCut) +{ + Model model("Master"); + auto x = model.addVar("x", 1, VarType::BINARY); + // x = 1 costs 1 in the master problem, whereas x = 0 costs 2 in the subproblem + auto* benders { model.includeBenders(1, x.getVar(), 2.0) }; + BOOST_REQUIRE(benders != nullptr); + const auto* cut { model.includeBenderscut(*benders, x.getVar(), 2.0) }; + BOOST_REQUIRE(cut != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(model.getStatus() == SCIP_STATUS_OPTIMAL); + BOOST_TEST(cut->nCuts > 0); + BOOST_TEST(model.getSolvingStatistic(statistics::PRIMALBOUND) == 1.0, boost::test_tools::tolerance(1e-6)); + BOOST_TEST(x.getSolValAsInt(model.getBestSol()) == 1); +} + +BOOST_AUTO_TEST_CASE(IncludeBenderscutTwice) +{ + Model model("Master"); + auto x = model.addVar("x", 1, VarType::BINARY); + auto* benders { model.includeBenders(1, x.getVar(), 2.0) }; + BOOST_REQUIRE(benders != nullptr); + BOOST_TEST(model.includeBenderscut(*benders, x.getVar(), 2.0) != nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + // SCIP rejects a second cut with the same name for the same Benders' decomposition, the cut is then deleted by + // SCIP++ + BOOST_TEST(model.includeBenderscut(*benders, x.getVar(), 2.0) == nullptr); + BOOST_TEST(model.getLastReturnCode() == SCIP_INVALIDDATA); +} + +/** + * Tracks whether it was deleted. + */ +class TrackingProbData : public scip::ObjProbData { + bool& m_deleted; + +public: + explicit TrackingProbData(bool& deleted) + : m_deleted(deleted) + { + } + ~TrackingProbData() override + { + m_deleted = true; + } +}; + +BOOST_AUTO_TEST_CASE(UseProblemData) +{ + bool deleted { false }; + { + Model model("Simple", make_unique(deleted)); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.addVar("x", 1); + model.solve(); + BOOST_TEST(model.getStatus() == SCIP_STATUS_OPTIMAL); + // plugins access the problem data via SCIP + BOOST_TEST(dynamic_cast(SCIPgetObjProbData(model.scip())) != nullptr); + BOOST_TEST(!deleted); + } + // SCIP deletes the problem data when the model is destructed + BOOST_TEST(deleted); +} + +/** + * Tracks whether it was deleted. + */ +class TrackingVardata : public scip::ObjVardata { + bool& m_deleted; + +public: + explicit TrackingVardata(bool& deleted) + : m_deleted(deleted) + { + } + ~TrackingVardata() override + { + m_deleted = true; + } +}; + +BOOST_AUTO_TEST_CASE(UseVariableData) +{ + bool deleted { false }; + { + Model model("Simple"); + auto& x = model.addVar("x", make_unique(deleted), 1, VarType::BINARY); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.addConstr(x >= 1, "lower"); + model.solve(); + BOOST_TEST(model.getStatus() == SCIP_STATUS_OPTIMAL); + BOOST_TEST(x.getSolValAsInt(model.getBestSol()) == 1); + // plugins access the variable data via SCIP + BOOST_TEST(dynamic_cast(SCIPgetObjVardata(model.scip(), x.getVar())) != nullptr); + BOOST_TEST(!deleted); + } + // SCIP deletes the variable data when the model is destructed + BOOST_TEST(deleted); +} + +BOOST_AUTO_TEST_CASE(AddVariableDataWithBounds) +{ + Model model("Simple"); + auto& x = model.addVar("x", make_unique(), 1, VarType::CONTINUOUS, 2.0, 3.0); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + BOOST_TEST(SCIPvarGetLbOriginal(x.getVar()) == 2.0); + BOOST_TEST(SCIPvarGetUbOriginal(x.getVar()) == 3.0); +} + +BOOST_AUTO_TEST_CASE(AddVariableDataFails) +{ + bool deleted { false }; + Model model("Simple"); + // by default, SCIP++ continues after a failed call, but a variable that was not created cannot be added + model.setScipCallWrapper([](SCIP_Retcode retcode) { + if (retcode != SCIP_OKAY) { + throw runtime_error("SCIP call failed"); + } + }); + // SCIP rejects an infinite objective coefficient, the variable data is then deleted by SCIP++ + BOOST_CHECK_THROW( + model.addVar("x", make_unique(deleted), SCIPinfinity(model.scip())), runtime_error); + BOOST_TEST(deleted); +} + +/** + * Forwards all messages to a file. + */ +class FileMessageHandler : public scip::ObjMessagehdlr { + ofstream m_file; + + void write(const char* msg) + { + if (msg) { + m_file << msg; + } else { + m_file.flush(); + } + } + +public: + explicit FileMessageHandler(const filesystem::path& path) + : scip::ObjMessagehdlr(false) + , m_file(path) + { + } + SCIP_DECL_MESSAGEWARNING(scip_warning) + override + { + write(msg); + } + SCIP_DECL_MESSAGEDIALOG(scip_dialog) + override + { + write(msg); + } + SCIP_DECL_MESSAGEINFO(scip_info) + override + { + write(msg); + } +}; + +BOOST_AUTO_TEST_CASE(UseMessageHandler) +{ + const auto LOG_FILE { uniqueTempPath("log") }; + { // SCIP deletes the handler, and thereby closes the file, when the model is destructed + Model model("Simple"); + auto x1 = model.addVar("x_1", 1); + auto x2 = model.addVar("x_2", 1); + model.addConstr(x1 + x2 >= 1, "capacity"); + model.setMessagehdlr(make_unique(LOG_FILE)); + BOOST_TEST(model.getLastReturnCode() == SCIP_OKAY); + model.solve(); + BOOST_TEST(model.getNSols() > 0); + } + const auto LOG { readAndRemove(LOG_FILE) }; + BOOST_TEST(LOG.find("SCIP Status") != string::npos); + BOOST_TEST(LOG.find("optimal solution found") != string::npos); } BOOST_AUTO_TEST_SUITE_END()