diff --git a/examples/nqueens.py b/examples/nqueens.py index 84e07bb626..3dc383caee 100644 --- a/examples/nqueens.py +++ b/examples/nqueens.py @@ -11,17 +11,17 @@ h = highspy.Highs() h.silent() -x = np.reshape(h.addBinaries(N*N), (N, N)) +x = h.addBinaries(N, N) -h.addConstrs(sum(x[i,:]) == 1 for i in range(N)) # each row has exactly one queen -h.addConstrs(sum(x[:,j]) == 1 for j in range(N)) # each col has exactly one queen +h.addConstrs(h.qsum(x[i,:]) == 1 for i in range(N)) # each row has exactly one queen +h.addConstrs(h.qsum(x[:,j]) == 1 for j in range(N)) # each col has exactly one queen y = np.fliplr(x) -h.addConstrs(x.diagonal(k).sum() <= 1 for k in range(-N + 1, N)) # each diagonal has at most one queen -h.addConstrs(y.diagonal(k).sum() <= 1 for k in range(-N + 1, N)) # each 'reverse' diagonal has at most one queen +h.addConstrs(h.qsum(x.diagonal(k)) <= 1 for k in range(-N + 1, N)) # each diagonal has at most one queen +h.addConstrs(h.qsum(y.diagonal(k)) <= 1 for k in range(-N + 1, N)) # each 'reverse' diagonal has at most one queen h.solve() -sol = np.array(h.vals(x)) +sol = h.vals(x) print('Queens:') diff --git a/src/highs_bindings.cpp b/src/highs_bindings.cpp index debbde0cc8..a2e8cf51c1 100644 --- a/src/highs_bindings.cpp +++ b/src/highs_bindings.cpp @@ -12,6 +12,34 @@ namespace py = pybind11; using namespace pybind11::literals; +// arrays are assumed to be contiguous c-style arrays of correct type +// * c_style forces the array to be stored in C-style contiguous order +// * forcecast converts the array to the correct type if needed +template +using dense_array_t = py::array_t; + +// wrapper for raw pointers +template +class readonly_ptr_wrapper { + public: + readonly_ptr_wrapper() : ptr(nullptr) {} + readonly_ptr_wrapper(T* ptr) : ptr(ptr) {} + readonly_ptr_wrapper(const readonly_ptr_wrapper& other) : ptr(other.ptr) {} + T& operator*() const { return *ptr; } + T* operator->() const { return ptr; } + T* get() const { return ptr; } + T& operator[](std::size_t idx) const { return ptr[idx]; } + bool is_valid() { return ptr != nullptr; } + + py::array_t to_array(std::size_t size) { + return py::array_t(py::buffer_info( + ptr, sizeof(T), py::format_descriptor::format(), 1, {size}, {1})); + } + + private: + T* ptr; +}; + HighsStatus highs_passModel(Highs* h, HighsModel& model) { return h->passModel(model); } @@ -20,13 +48,15 @@ HighsStatus highs_passModelPointers( Highs* h, const HighsInt num_col, const HighsInt num_row, const HighsInt num_nz, const HighsInt q_num_nz, const HighsInt a_format, const HighsInt q_format, const HighsInt sense, const double offset, - const py::array_t col_cost, const py::array_t col_lower, - const py::array_t col_upper, const py::array_t row_lower, - const py::array_t row_upper, const py::array_t a_start, - const py::array_t a_index, const py::array_t a_value, - const py::array_t q_start, const py::array_t q_index, - const py::array_t q_value, - const py::array_t integrality) { + const dense_array_t col_cost, const dense_array_t col_lower, + const dense_array_t col_upper, + const dense_array_t row_lower, + const dense_array_t row_upper, + const dense_array_t a_start, + const dense_array_t a_index, const dense_array_t a_value, + const dense_array_t q_start, + const dense_array_t q_index, const dense_array_t q_value, + const dense_array_t integrality) { py::buffer_info col_cost_info = col_cost.request(); py::buffer_info col_lower_info = col_lower.request(); py::buffer_info col_upper_info = col_upper.request(); @@ -65,15 +95,19 @@ HighsStatus highs_passModelPointers( HighsStatus highs_passLp(Highs* h, HighsLp& lp) { return h->passModel(lp); } -HighsStatus highs_passLpPointers( - Highs* h, const HighsInt num_col, const HighsInt num_row, - const HighsInt num_nz, const HighsInt a_format, const HighsInt sense, - const double offset, const py::array_t col_cost, - const py::array_t col_lower, const py::array_t col_upper, - const py::array_t row_lower, const py::array_t row_upper, - const py::array_t a_start, const py::array_t a_index, - const py::array_t a_value, - const py::array_t integrality) { +HighsStatus highs_passLpPointers(Highs* h, const HighsInt num_col, + const HighsInt num_row, const HighsInt num_nz, + const HighsInt a_format, const HighsInt sense, + const double offset, + const dense_array_t col_cost, + const dense_array_t col_lower, + const dense_array_t col_upper, + const dense_array_t row_lower, + const dense_array_t row_upper, + const dense_array_t a_start, + const dense_array_t a_index, + const dense_array_t a_value, + const dense_array_t integrality) { py::buffer_info col_cost_info = col_cost.request(); py::buffer_info col_lower_info = col_lower.request(); py::buffer_info col_upper_info = col_upper.request(); @@ -110,9 +144,9 @@ HighsStatus highs_passHessian(Highs* h, HighsHessian& hessian) { HighsStatus highs_passHessianPointers(Highs* h, const HighsInt dim, const HighsInt num_nz, const HighsInt format, - const py::array_t q_start, - const py::array_t q_index, - const py::array_t q_value) { + const dense_array_t q_start, + const dense_array_t q_index, + const dense_array_t q_value) { py::buffer_info q_start_info = q_start.request(); py::buffer_info q_index_info = q_index.request(); py::buffer_info q_value_info = q_value.request(); @@ -150,8 +184,8 @@ std::tuple highs_getRanging(Highs* h) { } HighsStatus highs_addRow(Highs* h, double lower, double upper, - HighsInt num_new_nz, py::array_t indices, - py::array_t values) { + HighsInt num_new_nz, dense_array_t indices, + dense_array_t values) { py::buffer_info indices_info = indices.request(); py::buffer_info values_info = values.request(); @@ -161,11 +195,12 @@ HighsStatus highs_addRow(Highs* h, double lower, double upper, return h->addRow(lower, upper, num_new_nz, indices_ptr, values_ptr); } -HighsStatus highs_addRows(Highs* h, HighsInt num_row, py::array_t lower, - py::array_t upper, HighsInt num_new_nz, - py::array_t starts, - py::array_t indices, - py::array_t values) { +HighsStatus highs_addRows(Highs* h, HighsInt num_row, + dense_array_t lower, + dense_array_t upper, HighsInt num_new_nz, + dense_array_t starts, + dense_array_t indices, + dense_array_t values) { py::buffer_info lower_info = lower.request(); py::buffer_info upper_info = upper.request(); py::buffer_info starts_info = starts.request(); @@ -183,8 +218,8 @@ HighsStatus highs_addRows(Highs* h, HighsInt num_row, py::array_t lower, } HighsStatus highs_addCol(Highs* h, double cost, double lower, double upper, - HighsInt num_new_nz, py::array_t indices, - py::array_t values) { + HighsInt num_new_nz, dense_array_t indices, + dense_array_t values) { py::buffer_info indices_info = indices.request(); py::buffer_info values_info = values.request(); @@ -194,11 +229,13 @@ HighsStatus highs_addCol(Highs* h, double cost, double lower, double upper, return h->addCol(cost, lower, upper, num_new_nz, indices_ptr, values_ptr); } -HighsStatus highs_addCols(Highs* h, HighsInt num_col, py::array_t cost, - py::array_t lower, py::array_t upper, - HighsInt num_new_nz, py::array_t starts, - py::array_t indices, - py::array_t values) { +HighsStatus highs_addCols(Highs* h, HighsInt num_col, + dense_array_t cost, + dense_array_t lower, + dense_array_t upper, HighsInt num_new_nz, + dense_array_t starts, + dense_array_t indices, + dense_array_t values) { py::buffer_info cost_info = cost.request(); py::buffer_info lower_info = lower.request(); py::buffer_info upper_info = upper.request(); @@ -222,8 +259,8 @@ HighsStatus highs_addVar(Highs* h, double lower, double upper) { } HighsStatus highs_addVars(Highs* h, HighsInt num_vars, - py::array_t lower, - py::array_t upper) { + dense_array_t lower, + dense_array_t upper) { py::buffer_info lower_info = lower.request(); py::buffer_info upper_info = upper.request(); @@ -234,8 +271,8 @@ HighsStatus highs_addVars(Highs* h, HighsInt num_vars, } HighsStatus highs_changeColsCost(Highs* h, HighsInt num_set_entries, - py::array_t indices, - py::array_t cost) { + dense_array_t indices, + dense_array_t cost) { py::buffer_info indices_info = indices.request(); py::buffer_info cost_info = cost.request(); @@ -246,9 +283,9 @@ HighsStatus highs_changeColsCost(Highs* h, HighsInt num_set_entries, } HighsStatus highs_changeColsBounds(Highs* h, HighsInt num_set_entries, - py::array_t indices, - py::array_t lower, - py::array_t upper) { + dense_array_t indices, + dense_array_t lower, + dense_array_t upper) { py::buffer_info indices_info = indices.request(); py::buffer_info lower_info = lower.request(); py::buffer_info upper_info = upper.request(); @@ -261,9 +298,9 @@ HighsStatus highs_changeColsBounds(Highs* h, HighsInt num_set_entries, upper_ptr); } -HighsStatus highs_changeColsIntegrality(Highs* h, HighsInt num_set_entries, - py::array_t indices, - py::array_t integrality) { +HighsStatus highs_changeColsIntegrality( + Highs* h, HighsInt num_set_entries, dense_array_t indices, + dense_array_t integrality) { py::buffer_info indices_info = indices.request(); py::buffer_info integrality_info = integrality.request(); @@ -291,8 +328,8 @@ HighsStatus highs_setSolution(Highs* h, HighsSolution& solution) { } HighsStatus highs_setSparseSolution(Highs* h, HighsInt num_entries, - py::array_t index, - py::array_t value) { + dense_array_t index, + dense_array_t value) { py::buffer_info index_info = index.request(); py::buffer_info value_info = value.request(); @@ -399,7 +436,7 @@ std::tuple highs_getCol( return std::make_tuple(status, cost, lower, upper, get_num_nz); } -std::tuple, py::array_t> +std::tuple, dense_array_t> highs_getColEntries(Highs* h, HighsInt col) { double cost, lower, upper; HighsInt get_num_col; @@ -430,7 +467,7 @@ std::tuple highs_getRow(Highs* h, return std::make_tuple(status, lower, upper, get_num_nz); } -std::tuple, py::array_t> +std::tuple, dense_array_t> highs_getRowEntries(Highs* h, HighsInt row) { double cost, lower, upper; HighsInt get_num_row; @@ -449,10 +486,10 @@ highs_getRowEntries(Highs* h, HighsInt row) { return std::make_tuple(status, py::cast(index), py::cast(value)); } -std::tuple, py::array_t, - py::array_t, HighsInt> +std::tuple, dense_array_t, + dense_array_t, HighsInt> highs_getCols(Highs* h, HighsInt num_set_entries, - py::array_t indices) { + dense_array_t indices) { py::buffer_info indices_info = indices.request(); HighsInt* indices_ptr = static_cast(indices_info.ptr); // Make sure that the vectors are not empty @@ -472,10 +509,10 @@ highs_getCols(Highs* h, HighsInt num_set_entries, py::cast(upper), get_num_nz); } -std::tuple, py::array_t, - py::array_t> +std::tuple, dense_array_t, + dense_array_t> highs_getColsEntries(Highs* h, HighsInt num_set_entries, - py::array_t indices) { + dense_array_t indices) { py::buffer_info indices_info = indices.request(); HighsInt* indices_ptr = static_cast(indices_info.ptr); // Make sure that the vectors are not empty @@ -498,10 +535,10 @@ highs_getColsEntries(Highs* h, HighsInt num_set_entries, py::cast(value)); } -std::tuple, py::array_t, +std::tuple, dense_array_t, HighsInt> highs_getRows(Highs* h, HighsInt num_set_entries, - py::array_t indices) { + dense_array_t indices) { py::buffer_info indices_info = indices.request(); HighsInt* indices_ptr = static_cast(indices_info.ptr); // Make sure that the vectors are not empty @@ -519,10 +556,10 @@ highs_getRows(Highs* h, HighsInt num_set_entries, get_num_nz); } -std::tuple, py::array_t, - py::array_t> +std::tuple, dense_array_t, + dense_array_t> highs_getRowsEntries(Highs* h, HighsInt num_set_entries, - py::array_t indices) { + dense_array_t indices) { py::buffer_info indices_info = indices.request(); HighsInt* indices_ptr = static_cast(indices_info.ptr); // Make sure that the vectors are not empty @@ -573,11 +610,22 @@ std::tuple highs_getRowByName(Highs* h, return std::make_tuple(status, row); } -HighsStatus highs_run(Highs* h) { - py::gil_scoped_release release; - HighsStatus status = h->run(); - py::gil_scoped_acquire(); - return status; +// Wrap the setCallback function to appropriately handle user data. +// pybind11 automatically ensures GIL is re-acquired when the callback is called. +HighsStatus highs_setCallback( + Highs* h, + std::function fn, + py::handle data) { + if (static_cast(fn) == false) + return h->setCallback((HighsCallbackFunctionType) nullptr, nullptr); + else + return h->setCallback( + [fn, data](int callbackType, const std::string& msg, + const HighsCallbackDataOut* dataOut, + HighsCallbackDataIn* dataIn, void* d) { + return fn(callbackType, msg, dataOut, dataIn, py::handle(reinterpret_cast(d))); + }, + data.ptr()); } PYBIND11_MODULE(_core, m) { @@ -879,7 +927,8 @@ PYBIND11_MODULE(_core, m) { .def("writeBasis", &Highs::writeBasis) .def("postsolve", &highs_postsolve) .def("postsolve", &highs_mipPostsolve) - .def("run", &highs_run) + .def("run", &Highs::run, py::call_guard()) + .def("resetGlobalScheduler", &Highs::resetGlobalScheduler) .def( "feasibilityRelaxation", [](Highs& self, double global_lower_penalty, @@ -914,7 +963,7 @@ PYBIND11_MODULE(_core, m) { py::arg("local_upper_penalty") = py::none(), py::arg("local_rhs_penalty") = py::none()) .def("getIis", &Highs::getIis) - .def("presolve", &Highs::presolve) + .def("presolve", &Highs::presolve, py::call_guard()) .def("writeSolution", &highs_writeSolution) .def("readSolution", &Highs::readSolution) .def("setOptionValue", @@ -1014,10 +1063,7 @@ PYBIND11_MODULE(_core, m) { .def("solutionStatusToString", &Highs::solutionStatusToString) .def("basisStatusToString", &Highs::basisStatusToString) .def("basisValidityToString", &Highs::basisValidityToString) - .def( - "setCallback", - static_cast( - &Highs::setCallback)) + .def("setCallback", &highs_setCallback) .def("startCallback", static_cast( &Highs::startCallback)) @@ -1232,6 +1278,7 @@ PYBIND11_MODULE(_core, m) { .value("kCallbackSimplexInterrupt", HighsCallbackType::kCallbackSimplexInterrupt) .value("kCallbackIpmInterrupt", HighsCallbackType::kCallbackIpmInterrupt) + .value("kCallbackMipSolution", HighsCallbackType::kCallbackMipSolution) .value("kCallbackMipImprovingSolution", HighsCallbackType::kCallbackMipImprovingSolution) .value("kCallbackMipLogging", HighsCallbackType::kCallbackMipLogging) @@ -1244,6 +1291,11 @@ PYBIND11_MODULE(_core, m) { .value("kNumCallbackType", HighsCallbackType::kNumCallbackType) .export_values(); // Classes + py::class_>(m, "readonly_ptr_wrapper_double") + .def(py::init()) + .def("__getitem__", &readonly_ptr_wrapper::operator[]) + .def("__bool__", &readonly_ptr_wrapper::is_valid) + .def("to_array", &readonly_ptr_wrapper::to_array); py::class_(callbacks, "HighsCallbackDataOut") .def(py::init<>()) .def_readwrite("log_type", &HighsCallbackDataOut::log_type) @@ -1261,15 +1313,10 @@ PYBIND11_MODULE(_core, m) { &HighsCallbackDataOut::mip_primal_bound) .def_readwrite("mip_dual_bound", &HighsCallbackDataOut::mip_dual_bound) .def_readwrite("mip_gap", &HighsCallbackDataOut::mip_gap) - .def_property( + .def_property_readonly( "mip_solution", - [](const HighsCallbackDataOut& self) -> py::array { - // XXX: This is clearly wrong, most likely we need to have the - // length as an input data parameter - return py::array(3, self.mip_solution); - }, - [](HighsCallbackDataOut& self, py::array_t new_mip_solution) { - self.mip_solution = new_mip_solution.mutable_data(); + [](const HighsCallbackDataOut& self) -> readonly_ptr_wrapper { + return readonly_ptr_wrapper(self.mip_solution); }); py::class_(callbacks, "HighsCallbackDataIn") .def(py::init<>()) diff --git a/src/highspy/highs.py b/src/highspy/highs.py index bdc1710ded..3c91eb613b 100644 --- a/src/highspy/highs.py +++ b/src/highspy/highs.py @@ -13,6 +13,7 @@ HighsInfoType, HighsStatus, HighsLogType, + cb, # classes HighsSparseMatrix, HighsLp, @@ -29,38 +30,39 @@ HighsRanging, # constants kHighsInf, - kHighsIInf, + kHighsIInf ) from collections.abc import Mapping -from itertools import groupby, product -from operator import itemgetter +from itertools import product from decimal import Decimal -from threading import Thread - - -class _ThreadingResult: - def __init__(self): - self.out = None - +from threading import Thread, local, RLock, Lock +import numpy as np class Highs(_Highs): - """HiGHS solver interface""" - + """ + HiGHS solver interface + """ def __init__(self): super().__init__() - # Silence logging - def silent(self): - """Disables solver output to the console.""" - super().setOptionValue("output_flag", False) + self.__handle_keyboard_interrupt = False + self.__handle_user_interrupt = False + self.__solver_should_stop = False + self.__solver_stopped = RLock() + self.__solver_started = Lock() + self.__solver_status = None - def _run(self, res): - res.out = super().run() + self.callbacks = [HighsCallback(cb.HighsCallbackType(_), self) for _ in range(int(cb.HighsCallbackType.kCallbackMax) + 1)] + self.enableCallbacks() - def run(self): - return self.solve() - + # Silence logging + def silent(self, turn_off_output=True): + """ + Disables solver output to the console. + """ + super().setOptionValue("output_flag", not turn_off_output) + # solve def solve(self): """Runs the solver on the current problem. @@ -68,14 +70,125 @@ def solve(self): Returns: A HighsStatus object containing the solve status. """ - res = _ThreadingResult() - t = Thread(target=self._run, args=(res,)) - t.start() - t.join() - return res.out + if self.HandleKeyboardInterrupt == False: + return super().run() + else: + return self.joinSolve(self.startSolve()) + + def startSolve(self): + """ + Starts the solver in a separate thread. Useful for handling KeyboardInterrupts. + Do not attempt to modify the model while the solver is running. + + Returns: + A Thread object representing the solver thread. + """ + if self.is_solver_running() == False: + self.__solver_started.acquire() + self.__solver_should_stop = False + self.__solver_status = None + + t = Thread(target=Highs.__solve, args=(self,), daemon=True) + t.start() + + # wait for solver thread to start to avoid synchronization issues + try: + self.__solver_started.acquire(True) + finally: + self.__solver_started.release() + return t + else: + raise Exception("Solver is already running.") + + def is_solver_running(self): + is_running = True + try: + # try to acquire lock, if we can't, solver is already running + is_running = not self.__solver_stopped.acquire(False) + return is_running + finally: + if is_running == False: + self.__solver_stopped.release() + + # internal solve method for use with threads + # will set the status of the solver when finished and release the shared lock + def __solve(self): + try: + self.__solver_stopped.acquire(True) + self.__solver_started.release() # allow main thread to continue + self.__solver_status = super().run() + + # avoid potential deadlock in Windows + # can remove once HiGHS is updated to handle this internally + _Highs.resetGlobalScheduler(False) + finally: + self.__solver_stopped.release() + + def joinSolve(self, solver_thread=None, interrupt_limit=5): + """ + Waits for the solver to finish. If solver_thread is provided, it will handle KeyboardInterrupts. + + Args: + solver_thread: A Thread object representing the solver thread (optional). + interrupt_limit: The number of times to allow KeyboardInterrupt before forcing termination (optional). + + Returns: + A HighsStatus object containing the solve status. + """ + if solver_thread is not None and interrupt_limit <= 0: + result = (False, None) + + try: + while result[0] == False: + result = self.wait(0.1) + return result[1] + + except KeyboardInterrupt: + print('KeyboardInterrupt: Waiting for HiGHS to finish...') + self.cancelSolve() + + elif interrupt_limit > 0: + result = (False, None) + + for count in range(interrupt_limit): + try: + while result[0] == False: + result = self.wait(0.1) + return result[1] + + except KeyboardInterrupt: + print(f'Ctrl-C pressed {count+1} times: Waiting for HiGHS to finish. ({interrupt_limit} times to force termination)') + self.cancelSolve() + + # if we reach this point, we should force termination + print("Forcing termination...") + exit(1) + + try: + # wait for shared lock, i.e., solver to finish + self.__solver_stopped.acquire(True) + except KeyboardInterrupt: + pass # ignore additional KeyboardInterrupt here + finally: + self.__solver_stopped.release() + + return self.__solver_status + + def wait(self, timeout=-1.0): + result = False, None + + try: + result = self.__solver_stopped.acquire(True, timeout=timeout), self.__solver_status + return result + finally: + if result[0] == True: + self.__solver_status = None # reset status + self.__solver_stopped.release() def optimize(self): - """Alias for the solve method.""" + """ + Alias for the solve method. + """ return self.solve() # reset the objective and sense, then solve @@ -91,21 +204,21 @@ def minimize(self, obj=None): Returns: A HighsStatus object containing the solve status after minimization. """ - if obj != None: + if obj is not None: # if we have a single variable, wrap it in a linear expression if isinstance(obj, highs_var) == True: obj = highs_linear_expression(obj) - if isinstance(obj, highs_linear_expression) == False or obj.LHS != -self.inf or obj.RHS != self.inf: + elif isinstance(obj, highs_linear_expression) == False or obj.bounds != None: raise Exception('Objective cannot be an inequality') # reset objective super().changeColsCost(self.numVariables, range(self.numVariables), [0]*self.numVariables) # if we have duplicate variables, add the vals - vars,vals = zip(*[(var, sum(v[1] for v in Vals)) for var, Vals in groupby(sorted(zip(obj.vars, obj.vals)), key=itemgetter(0))]) + vars,vals = obj.unique_elements() super().changeColsCost(len(vars), vars, vals) - super().changeObjectiveOffset(obj.constant) + super().changeObjectiveOffset(obj.constant or 0.0) super().changeObjectiveSense(ObjSense.kMinimize) return self.solve() @@ -123,46 +236,52 @@ def maximize(self, obj=None): Returns: A HighsStatus object containing the solve status after maximization. """ - if obj != None: + if obj is not None: # if we have a single variable, wrap it in a linear expression if isinstance(obj, highs_var) == True: obj = highs_linear_expression(obj) - if isinstance(obj, highs_linear_expression) == False or obj.LHS != -self.inf or obj.RHS != self.inf: + elif isinstance(obj, highs_linear_expression) == False or obj.bounds != None: raise Exception('Objective cannot be an inequality') # reset objective super().changeColsCost(self.numVariables, range(self.numVariables), [0]*self.numVariables) # if we have duplicate variables, add the vals - vars,vals = zip(*[(var, sum(v[1] for v in Vals)) for var, Vals in groupby(sorted(zip(obj.vars, obj.vals)), key=itemgetter(0))]) + vars,vals = obj.unique_elements() super().changeColsCost(len(vars), vars, vals) - super().changeObjectiveOffset(obj.constant) + super().changeObjectiveOffset(obj.constant or 0.0) super().changeObjectiveSense(ObjSense.kMaximize) return self.solve() - def internal_get_value(self, var_index_collection, col_value): - """Internal method to get the value of a variable in the solution. Could be value or dual.""" - if isinstance(var_index_collection, int): - return col_value[var_index_collection] - elif isinstance(var_index_collection, highs_var): - return col_value[var_index_collection.index] - elif isinstance(var_index_collection, Mapping): - return {k: col_value[v.index] for k,v in var_index_collection.items()} + @staticmethod + def internal_get_value(array_values, index_collection): + """ + Internal method to get the value of an index from an array of values. Could be value or dual, variable or constraint. + """ + if isinstance(index_collection, (int, highs_var, highs_cons)): + return array_values[int(index_collection)] + + elif isinstance(index_collection, highs_linear_expression): + return index_collection.evaluate(array_values) + + elif isinstance(index_collection, Mapping): + return {k: Highs.internal_get_value(array_values, v) for k,v in index_collection.items()} + else: - return [col_value[v.index] for v in var_index_collection] + return np.asarray([Highs.internal_get_value(array_values, v) for v in index_collection]) def val(self, var): - """Gets the value of a variable in the solution. + """Gets the value of a variable/index or expression in the solution. Args: - var: A highs_var object representing the variable. + var: A highs_var/index or highs_linear_expression object representing the variable. Returns: The value of the variable in the solution. """ - return super().getSolution().col_value[var.index] + return Highs.internal_get_value(super().getSolution().col_value, var) def vals(self, vars): """Gets the values of multiple variables in the solution. @@ -173,8 +292,7 @@ def vals(self, vars): Returns: If vars is a Mapping, returns a dict where keys are the same keys from the input vars and values are the solution values of the corresponding variables. If vars is an iterable, returns a list of solution values for the variables. """ - col_value = super().getSolution().col_value - return {k: self.internal_get_value(v, col_value) for k,v in vars.items()} if isinstance(vars, Mapping) else [self.internal_get_value(v, col_value) for v in vars] + return Highs.internal_get_value(super().getSolution().col_value, vars) def variableName(self, var): """Retrieves the name of a specific variable. @@ -188,7 +306,7 @@ def variableName(self, var): Returns: The name of the specified variable. """ - [status, name] = super().getColName(var.index) + [status, name] = super().getColName(int(var)) failed = status != HighsStatus.kOk if failed: raise Exception('Variable name not found') @@ -229,7 +347,7 @@ def variableValue(self, var): Returns: The value of the specified variable in the solution. """ - return super().getSolution().col_value[var.index] + return self.val(var) def variableValues(self, vars): """Retrieves the values of multiple variables in the solution. @@ -252,7 +370,7 @@ def allVariableValues(self): return super().getSolution().col_value def variableDual(self, var): - """Retrieves the dual value of a specific variable in the solution. + """Retrieves the dual value of a specific variable/index or expression in the solution. Args: var: A highs_var object representing the variable. @@ -260,7 +378,7 @@ def variableDual(self, var): Returns: The dual value of the specified variable in the solution. """ - return super().getSolution().col_dual[var.index] + return Highs.internal_get_value(super().getSolution().col_dual, var) def variableDuals(self, vars): """Retrieves the dual values of multiple variables in the solution. @@ -271,8 +389,7 @@ def variableDuals(self, vars): Returns: If vars is a Mapping, returns a dict where keys are the same keys from the input vars and values are the dual values of the corresponding variables. If vars is an iterable, returns a list of dual values for the variables. """ - col_dual = super().getSolution() - return {k: self.internal_get_value(v, col_dual) for k,v in vars.items()} if isinstance(vars, Mapping) else [self.internal_get_value(v, col_dual) for v in vars] + return Highs.internal_get_value(super().getSolution().col_dual, vars) def allVariableDuals(self): @@ -292,7 +409,7 @@ def constrValue(self, con): Returns: The value of the specified constraint in the solution. """ - return super().getSolution().row_value[con.index] + return Highs.internal_get_value(super().getSolution().row_value, con) def constrValues(self, cons): """Retrieves the values of multiple constraints in the solution. @@ -303,9 +420,7 @@ def constrValues(self, cons): Returns: If cons is a Mapping, returns a dict where keys are the same keys from the input cons and values are the solution values of the corresponding constraints. If cons is an iterable, returns a list of solution values for the constraints. """ - row_value = super().getSolution().row_value - return {k: row_value[c.index] for k,c in cons.items()} if isinstance(cons, Mapping) else [row_value[c.index] for c in cons] - + return Highs.internal_get_value(super().getSolution().row_value, cons) def allConstrValues(self): """Retrieves the values of all constraints in the solution. @@ -324,7 +439,7 @@ def constrDual(self, con): Returns: The dual value of the specified constraint in the solution. """ - return super().getSolution().row_dual[con.index] + return Highs.internal_get_value(super().getSolution().row_dual, con) def constrDuals(self, cons): """Retrieves the dual values of multiple constraints in the solution. @@ -335,8 +450,7 @@ def constrDuals(self, cons): Returns: If cons is a Mapping, returns a dict where keys are the same keys from the input cons and values are the dual values of the corresponding constraints. If cons is an iterable, returns a list of dual values for the constraints. """ - row_dual = super().getSolution().row_dual - return {k: row_dual[c.index] for k,c in cons.items()} if isinstance(cons, Mapping) else [row_dual[c.index] for c in cons] + return Highs.internal_get_value(super().getSolution().row_dual, cons) def allConstrDuals(self): """Retrieves the dual values of all constraints in the solution. @@ -401,26 +515,28 @@ def addVariables(self, *nvars, **kwargs): vartype = kwargs.get('type', HighsVarType.kContinuous) name_prefix = kwargs.get('name_prefix', None) name = kwargs.get('name', None) - out_array = kwargs.get('out_array', len(nvars) == 1 and isinstance(nvars[0], int)) + out_array = kwargs.get('out_array', all([isinstance(n, int) for n in nvars])) + shape = list(map(int, nvars)) if all([isinstance(n, int) for n in nvars]) else 1 nvars = [range(n) if isinstance(n, int) else n for n in nvars] indices = list(nvars[0] if len(nvars) == 1 else product(*nvars)) # unpack tuple if needed N = len(indices) # parameter can be scalar, array, or mapping lookup (i.e., dictionary, custom class, etc.) # scalar: repeat for all N, array: use as is, lookup: convert to array using indices - R = lambda x: [x[i] for i in indices] if isinstance(x, Mapping) else (x if hasattr(x, "__getitem__") else [x] * N) + Rf = lambda x: np.fromiter((x[i] for i in indices), np.float64) if isinstance(x, Mapping) else (x if hasattr(x, "__getitem__") else np.full(N, x, dtype=np.float64)) + Ri = lambda x: np.fromiter((x[i] for i in indices), np.int32) if isinstance(x, Mapping) else (x if hasattr(x, "__getitem__") else np.full(N, x, dtype=np.int32)) start_idx = self.numVariables - idx = range(start_idx, start_idx + N) - status = super().addCols(N, R(obj), R(lb), R(ub), 0, [], [], []) + idx = np.arange(start_idx, start_idx + N, dtype=np.int32) + status = super().addCols(N, Rf(obj), Rf(lb), Rf(ub), 0, [], [], []) if status != HighsStatus.kOk: raise Exception("Failed to add columns to the model.") # only set integrality if we have non-continuous variables if vartype != HighsVarType.kContinuous: - super().changeColsIntegrality(N, idx, R(vartype)) + super().changeColsIntegrality(N, idx, Ri(vartype)) if name or name_prefix: names = name or [f"{name_prefix}{i}" for i in indices] @@ -428,15 +544,19 @@ def addVariables(self, *nvars, **kwargs): for i,n in zip(idx, names): super().passColName(int(i), str(n)) - return [highs_var(i, self) for i in idx] if out_array == True else {index: highs_var(i, self) for index,i in zip(indices, idx)} + return np.asarray([highs_var(i, self) for i in idx], dtype=highs_var).reshape(shape) if out_array == True else {index: highs_var(i, self) for index,i in zip(indices, idx)} def addIntegrals(self, *nvars, **kwargs): - """Alias for the addVariables method, for integer variables.""" + """ + Alias for the addVariables method, for integer variables. + """ kwargs.setdefault('type', HighsVarType.kInteger) return self.addVariables(*nvars, **kwargs) def addBinaries(self, *nvars, **kwargs): - """Alias for the addVariables method, for binary variables.""" + """ + Alias for the addVariables method, for binary variables. + """ kwargs.setdefault('lb', 0) kwargs.setdefault('ub', 1) kwargs.setdefault('type', HighsVarType.kInteger) @@ -444,11 +564,15 @@ def addBinaries(self, *nvars, **kwargs): return self.addVariables(*nvars, **kwargs) def addIntegral(self, lb = 0, ub = kHighsInf, obj = 0, name = None): - """Alias for the addVariable method, for integer variables.""" + """ + Alias for the addVariable method, for integer variables. + """ return self.addVariable(lb, ub, obj, HighsVarType.kInteger, name) def addBinary(self, obj = 0, name = None): - """Alias for the addVariable method, for binary variables.""" + """ + Alias for the addVariable method, for binary variables. + """ return self.addVariable(0, 1, obj, HighsVarType.kInteger, name) def deleteVariable(self, var_or_index, *args): @@ -459,7 +583,7 @@ def deleteVariable(self, var_or_index, *args): *args: Optional collections (lists, dicts, etc.) of highs_var objects whose indices need to be updated. """ # Determine the index of the variable to delete - index = var_or_index.index if isinstance(var_or_index, highs_var) else var_or_index + index = int(var_or_index) # Delete the variable from the model if it exists if index < self.numVariables: @@ -472,14 +596,22 @@ def deleteVariable(self, var_or_index, *args): for key, var in collection.items(): if var.index > index: var.index -= 1 + elif var.index == index: + var.index = -1 + elif hasattr(collection, '__iter__'): # Update indices in an iterable of variables for var in collection: if var.index > index: var.index -= 1 + elif var.index == index: + var.index = -1 + # If the collection is a single highs_var object, check and update if necessary elif isinstance(collection, highs_var) and collection.index > index: collection.index -= 1 + elif isinstance(collection, highs_var) and collection.index == index: + collection.index = -1 def getVariables(self): """Retrieves all variables in the model. @@ -519,26 +651,23 @@ def numConstrs(self): # # add constraints # - def addConstr(self, cons, name=None): + def addConstr(self, expr, name=None): """Adds a constraint to the model. Args: - cons: A highs_linear_expression to be added. + expr: A highs_linear_expression to be added. name: Optional name of the constraint. Returns: A highs_con object representing the added constraint. """ - # if we have duplicate variables, add the vals - vars,vals = zip(*[(var, sum(v[1] for v in Vals)) for var, Vals in groupby(sorted(zip(cons.vars, cons.vals)), key=itemgetter(0))]) - super().addRow(cons.LHS - cons.constant, cons.RHS - cons.constant, len(vars), vars, vals) - con = highs_cons(self.numConstrs - 1, self) + con = self.__addRow(expr, self.numConstrs) if name != None: super().passRowName(con.index, name) return con - + def addConstrs(self, *args, **kwargs): """Adds multiple constraints to the model. @@ -560,37 +689,75 @@ def addConstrs(self, *args, **kwargs): if len(args) == 1 and hasattr(args[0], "__iter__") == True: generator = args[0] - lower = [] - upper = [] - starts = [0] - indices = [] - values = [] - nnz = 0; + initial_rows = self.numConstrs - for cons in generator: - # if we have duplicate variables, add the vals together - vars,vals = zip(*[(var, sum(v[1] for v in Vals)) for var, Vals in groupby(sorted(zip(cons.vars, cons.vals)), key=itemgetter(0))]) - - indices.extend(vars) - values.extend(vals) - nnz += len(vars) + try: + if isinstance(generator, Mapping) == False: + cons = [self.__addRow(expr, initial_rows + count) for count, expr in enumerate(generator)] + else: + cons = {key: self.__addRow(expr, initial_rows + count) for count, (key, expr) in enumerate(generator.items())} - lower.append(cons.LHS - cons.constant) - upper.append(cons.RHS - cons.constant) - starts.append(nnz) + # TODO: Mapping support with constraing names can be improved, e.g., by allowing a name collection to be passed + if name or name_prefix: + names = name or [f"{name_prefix}{n}" for n in range(self.numConstrs - initial_rows)] - new_rows = len(lower) - super().addRows(new_rows, lower, upper, nnz, starts, indices, values); - cons = [highs_cons(self.numConstrs - new_rows + n, self) for n in range(new_rows)] + for c,n in zip(range(initial_rows, self.numConstrs), names): + super().passRowName(int(c), str(n)) - if name or name_prefix: - names = name or [f"{name_prefix}{n}" for n in range(new_rows)] + except Exception as e: + # rollback model if error - remove any constraints that were added + status = super().deleteRows(self.numConstrs - initial_rows, np.arange(initial_rows, self.numConstrs, dtype=np.int32)) - for c,n in zip(cons, names): - super().passRowName(int(c.index), str(n)) + if status != HighsStatus.kOk: + raise Exception("Failed to rollback model after failure. Model might be in a undeterminate state.") + else: + raise e return cons + def __addRow(self, expr, idx): + """ + Internal method to add a constraint to the model. + """ + if expr.bounds != None: + vars,vals = expr.unique_elements() # if we have duplicate variables, add the vals + super().addRow(expr.bounds[0], expr.bounds[1], len(vars), vars, vals) + return highs_cons(idx, self) + else: + raise Exception("Constraint bounds must be set via comparison (>=,==,<=).") + + def expr(self, optional=None): + """Creates a new highs_linear_expression object. + + Returns: + A highs_linear_expression object. + """ + return highs_linear_expression(optional) + + def getExpr(self, cons): + """Retrieves the highs_linear_expression of a constraint. + + Args: + cons: A highs_con object or index representing the constraint. + + Returns: + A highs_linear_expression object representing the expression of the constraint. + """ + status, lb, ub, nnz = super().getRow(int(cons)) + + if status != HighsStatus.kOk: + raise Exception("Error retrieving constraint expression.") + + status, idx, val = super().getRowEntries(int(cons)) + + if status != HighsStatus.kOk: + raise Exception("Error retrieving constraint expression entries.") + + expr = highs_linear_expression() + expr.bounds = [lb, ub] + expr.vars = list(idx) + expr.vals = list(val) + return expr def chgCoeff(self, cons, var, val): """Changes the coefficient of a variable in a constraint. @@ -600,7 +767,7 @@ def chgCoeff(self, cons, var, val): var: A highs_var object representing the variable. val: The new coefficient value for the variable in the constraint. """ - super().changeCoeff(cons.index, var.index, val) + super().changeCoeff(int(cons), int(var), val) def getConstrs(self): """Retrieves all constraints in the model. @@ -618,59 +785,344 @@ def removeConstr(self, cons_or_index, *args): *args: Optional collections (lists, dicts, etc.) of highs_cons objects whose indices need to be updated after the removal. """ # Determine the index of the constraint to delete - index = cons_or_index.index if isinstance(cons_or_index, highs_cons) else cons_or_index + index = int(cons_or_index) # Delete the variable from the model if it exists if index < self.numConstrs: - super().deleteRows(1, [index]) - - # Update the indices of constraints in the provided collections - for collection in args: - if isinstance(collection, dict): - # Update indices in a dictionary of constraints - for key, con in collection.items(): - if con.index > index: - con.index -= 1 - elif hasattr(collection, '__iter__'): - # Update indices in an iterable of constraints - for con in collection: - if con.index > index: - con.index -= 1 - # If the collection is a single highs_cons object, check and update if necessary - elif isinstance(collection, highs_cons) and collection.index > index: - collection.index -= 1 - + status = super().deleteRows(1, [index]) + + if status != HighsStatus.kOk: + raise Exception("Failed to delete constraint from the model.") + + # Update the indices of constraints in the provided collections + for collection in args: + if isinstance(collection, dict): + # Update indices in a dictionary of constraints + for key, con in collection.items(): + if con.index > index: + con.index -= 1 + elif con.index == index: + con.index = -1 + + elif hasattr(collection, '__iter__'): + # Update indices in an iterable of constraints + for con in collection: + if con.index > index: + con.index -= 1 + elif con.index == index: + con.index = -1 + # If the collection is a single highs_cons object, check and update if necessary + elif isinstance(collection, highs_cons) and collection.index > index: + collection.index -= 1 + elif isinstance(collection, highs_cons) and collection.index == index: + collection.index = -1 def setMinimize(self): - """Sets the objective sense of the model to minimization.""" + """ + Sets the objective sense of the model to minimization. + """ super().changeObjectiveSense(ObjSense.kMinimize) def setMaximize(self): - """Sets the objective sense of the model to maximization.""" + """ + Sets the objective sense of the model to maximization. + """ super().changeObjectiveSense(ObjSense.kMaximize) - def setInteger(self, var): - """Sets a variable's type to integer. + def setInteger(self, var_or_collection): + """Sets a variable/collection to integer. + + Args: + var_or_collection: A highs_var object/collection representing the variable to be set as integer. + """ + if hasattr(var_or_collection, '__iter__'): + idx = np.fromiter(map(int, var_or_collection), dtype=np.int32) + super().changeColsIntegrality(len(idx), idx, np.full(len(idx), HighsVarType.kInteger, dtype=np.int32)) + else: + super().changeColIntegrality(int(var_or_collection), HighsVarType.kInteger) + + def setContinuous(self, var_or_collection): + """Sets a variable/collection to continuous. Args: - var: A highs_var object representing the variable to be set as integer. + var_or_collection: A highs_var object/collection representing the variable to be set as continuous. """ - super().changeColIntegrality(var.index, HighsVarType.kInteger) + if hasattr(var_or_collection, '__iter__'): + idx = np.fromiter(map(int, var_or_collection), dtype=np.int32) + super().changeColsIntegrality(len(idx), idx, np.full(len(idx), HighsVarType.kContinuous, dtype=np.int32)) + else: + super().changeColIntegrality(int(var_or_collection), HighsVarType.kContinuous) + + @staticmethod + def qsum(items, initial=None): + """Performs a faster sum for highs_linear_expressions. + + Args: + items: A collection of highs_linear_expressions or highs_vars to be summed. + """ + expr = highs_linear_expression(initial) + + for v in items: + expr += v + + return expr + + ## + ## Callback support, with optional user interrupt handling + ## + @staticmethod + def __internal_callback(callback_type, message, data_out, data_in, user_callback_data): + user_callback_data.callbacks[int(callback_type)].fire(message, data_out, data_in) + + def enableCallbacks(self): + """ + Enables callbacks, restarting them if they were previously enabled. + """ + super().setCallback(Highs.__internal_callback, self) + + # restart callbacks if any exist + for cb in self.callbacks: + if len(cb.callbacks) > 0: + self.startCallback(cb.callback_type) + + def disableCallbacks(self): + """ + Disables all callbacks. + """ + status = super().setCallback(None, None) # this will also stop all callbacks + + if status != HighsStatus.kOk: + raise Exception("Failed to disable callbacks.") + + def cancelSolve(self): + """ + If HandleUserInterrupt is enabled, this method will signal the solver to stop. + """ + self.__solver_should_stop = True + + @property + def HandleKeyboardInterrupt(self): + """ + Get/Set whether the solver should handle KeyboardInterrupt (i.e., cancel solve on Ctrl+C). Also enables/disables HandleUserInterrupt. + """ + return self.__handle_keyboard_interrupt + + @HandleKeyboardInterrupt.setter + def HandleKeyboardInterrupt(self, value): + self.__handle_keyboard_interrupt = value + self.HandleUserInterrupt = value + + @property + def HandleUserInterrupt(self): + """ + Get/Set whether the solver should handle user interrupts (i.e., cancel solve on user request) + """ + return self.__handle_user_interrupt + + @HandleUserInterrupt.setter + def HandleUserInterrupt(self, value): + self.__handle_user_interrupt = value + + if value == True: + self.cbSimplexInterrupt += self.__user_interrupt_event + self.cbIpmInterrupt += self.__user_interrupt_event + self.cbMipInterrupt += self.__user_interrupt_event + else: + self.cbSimplexInterrupt -= self.__user_interrupt_event + self.cbIpmInterrupt -= self.__user_interrupt_event + self.cbMipInterrupt -= self.__user_interrupt_event + + def __user_interrupt_event(self, e): + if self.__solver_should_stop and e.data_in != None: + e.data_in.user_interrupt = True + + @property + def cbLogging(self): + return self.callbacks[int(cb.HighsCallbackType.kCallbackLogging)] + + @property + def cbSimplexInterrupt(self): + return self.callbacks[int(cb.HighsCallbackType.kCallbackSimplexInterrupt)] + + @property + def cbIpmInterrupt(self): + return self.callbacks[int(cb.HighsCallbackType.kCallbackIpmInterrupt)] + + @property + def cbMipSolution(self): + return self.callbacks[int(cb.HighsCallbackType.kCallbackMipSolution)] + + @property + def cbMipImprovingSolution(self): + return self.callbacks[int(cb.HighsCallbackType.kCallbackMipImprovingSolution)] + + @property + def cbMipLogging(self): + return self.callbacks[int(cb.HighsCallbackType.kCallbackMipLogging)] + + @property + def cbMipInterrupt(self): + return self.callbacks[int(cb.HighsCallbackType.kCallbackMipInterrupt)] + + @property + def cbMipGetCutPool(self): + return self.callbacks[int(cb.HighsCallbackType.kCallbackMipGetCutPool)] + + @property + def cbMipDefineLazyConstraints(self): + return self.callbacks[int(cb.HighsCallbackType.kCallbackMipDefineLazyConstraints)] + + # callback setters are required for +=/-= syntax + # e.g., h.cbLogging += my_callback + @cbLogging.setter + def cbLogging(self, value): + pass + + @cbSimplexInterrupt.setter + def cbSimplexInterrupt(self, value): + pass + + @cbIpmInterrupt.setter + def cbIpmInterrupt(self, value): + pass + + @cbMipSolution.setter + def cbMipSolution(self, value): + pass + + @cbMipImprovingSolution.setter + def cbMipImprovingSolution(self, value): + pass + + @cbMipLogging.setter + def cbMipLogging(self, value): + pass + + @cbMipInterrupt.setter + def cbMipInterrupt(self, value): + pass + + @cbMipGetCutPool.setter + def cbMipGetCutPool(self, value): + pass + + @cbMipDefineLazyConstraints.setter + def cbMipDefineLazyConstraints(self, value): + pass + +## +## Callback support +## +class HighsCallbackEvent(object): + __slots__ = ['message', 'data_out', 'data_in', 'user_data'] + + def __init__(self, message, data_out, data_in, user_data): + self.message = message + self.data_out = data_out + self.data_in = data_in + self.user_data = user_data + + def val(self, var): + """ + Gets the value(s) of a variable/index or expression in the callback solution. + """ + return Highs.internal_get_value(self.data_out.mip_solution, var) + +class HighsCallback(object): + __slots__ = ['callbacks', 'user_callback_data', 'highs', 'callback_type'] + + def __init__(self, callback_type, highs): + self.callbacks = [] + self.user_callback_data = [] + self.callback_type = callback_type + self.highs = highs + + def subscribe(self, callback, user_data=None): + """ + Subscribes a callback to the event. + + Args: + callback: The callback function to be executed. + user_data: Optional user data to be passed to the callback. + """ + if len(self.callbacks) == 0: + status = self.highs.startCallback(self.callback_type) + + if status != HighsStatus.kOk: + raise Exception("Failed to start callback.") + + self.callbacks.append(callback) + self.user_callback_data.append(user_data) + + def unsubscribe(self, callback): + """ + Unsubscribes a callback from the event. + + Args: + callback: The callback function to be removed. + """ + try: + idx = self.callbacks.index(callback) + del self.callbacks[idx] + del self.user_callback_data[idx] + + if len(self.callbacks) == 0: + self.highs.stopCallback(self.callback_type) - def setContinuous(self, var): - """Sets a variable's type to continuous. + except ValueError: + pass + + def unsubscribe_by_data(self, user_data): + """ + Unsubscribes a callback by user data. Args: - var: A highs_var object representing the variable to be set as continuous. + user_data: The user data corresponding to the callback(s) to be removed. + """ + idx = reversed([i for i,ud in enumerate(self.user_callback_data) if ud == user_data]) + + for i in idx: + del self.callbacks[i] + del self.user_callback_data[i] + + if len(self.callbacks) == 0: + self.highs.stopCallback(self.callback_type) + + def __iadd__(self, callback): + self.subscribe(callback) + return self + + def __isub__(self, callback): + self.unsubscribe(callback) + return self + + def clear(self): + """ + Unsubscribes all callbacks from the event. + """ + self.callbacks = [] + self.user_callback_data = [] + self.highs.stopCallback(self.callback_type) + + def fire(self, message, data_out, data_in): + """ + Fires the event, executing all subscribed callbacks. """ - super().changeColIntegrality(var.index, HighsVarType.kContinuous) + e = HighsCallbackEvent(message, data_out, data_in, None) + + for fn,user_data in zip(self.callbacks, self.user_callback_data): + e.user_data = user_data + fn(e) + ## The following classes keep track of variables ## It is currently quite basic and may fail in complex scenarios # highs variable class highs_var(object): - """Basic constraint builder for HiGHS""" + """ + Basic constraint builder for HiGHS + """ __slots__ = ['index', 'highs'] def __init__(self, i, highs): @@ -692,42 +1144,80 @@ def name(self): def name(self, value): self.highs.passColName(self.index, value) + def __int__(self): + return int(self.index) + def __hash__(self): - return self.index + return int(self.index) def __neg__(self): - return -1.0 * highs_linear_expression(self) + expr = highs_linear_expression(self) + expr *= -1.0 + return expr - def __le__(self, other): - return highs_linear_expression(self) <= other - - def __eq__(self, other): - return highs_linear_expression(self) == other - - def __ge__(self, other): - return highs_linear_expression(self) >= other - def __add__(self, other): - return highs_linear_expression(self) + other + expr = highs_linear_expression(self) + expr += other + return expr def __radd__(self, other): - return highs_linear_expression(self) + other + expr = highs_linear_expression(self) + expr += other + return expr def __mul__(self, other): - return highs_linear_expression(self) * other + expr = highs_linear_expression(self) + expr *= other + return expr def __rmul__(self, other): - return highs_linear_expression(self) * other + expr = highs_linear_expression(self) + expr *= other + return expr def __rsub__(self, other): - return -1.0 * highs_linear_expression(self) + other + expr = highs_linear_expression(other) + expr -= self + return expr def __sub__(self, other): - return highs_linear_expression(self) - other + expr = highs_linear_expression(self) + expr -= other + return expr + + # self <= other + def __le__(self, other): + if isinstance(other, highs_linear_expression): + return other.__ge__(self) + else: + return highs_linear_expression(self).__le__(other) + + # self == other + def __eq__(self, other): + if isinstance(other, highs_linear_expression): + return other.__eq__(self) + else: + return highs_linear_expression(self).__eq__(other) + + # self != other + def __ne__(self, other): + if other == None: + return True + else: + raise Exception('Invalid comparison.') + + # self >= other + def __ge__(self, other): + if isinstance(other, highs_linear_expression): + return other.__le__(self) + else: + return highs_linear_expression(self).__ge__(other) # highs constraint class highs_cons(object): - """Basic constraint for HiGHS""" + """ + Basic constraint for HiGHS + """ __slots__ = ['index', 'highs'] def __init__(self, i, highs): @@ -737,6 +1227,18 @@ def __init__(self, i, highs): def __repr__(self): return f"highs_cons({self.index})" + def __int__(self): + return int(self.index) + + def __hash__(self): + return int(self.index) + + def expr(self): + """ + Retrieves the expression of the constraint. + """ + return self.highs.getExpr(self) + @property def name(self): status, name = self.highs.getRowName(self.index) @@ -751,147 +1253,626 @@ def name(self, value): # highs constraint builder +# +# Note: we only allow LHS/RHS to be set once via comparisons (>=,==,<=), otherwise it gets confusing! +# e.g, for (0 <= x <= 1) <= 2, should this be: +# 0 <= x <= 1 (i.e., tighter bound, x <= 1 and x <= 2 implies x <= 1), +# or, 0 <= x <= 2 (i.e., last comparision overrides previous)? +# +# Throwing an error makes it obvious to the user. Note we can still set via addition, +# e.g., (x <= 1) + (y <= 5) to get x + y <= 6 +# +# +# For chained comparisons, we throw an error if we have mismatched variables in the "bounds", +# e.g., x <= y <= 1. This requires 2 constraints, x <= 1 and x <= y, and is not supported, whereas +# x + 2 <= y <= x + 5 is supported, since we can easily rewrite this as 2 <= y - x <= 5. +# +# As such, trivial chaining is supported, e.g., lb <= expr <= ub, where lb and ub are numeric. +# +# Note: when dealing with inequalities (>=,==,<=) we need to decide which variables to move the LHS/RHS. +# For consistency, we: +# 1. Move variables to side with the most variables, e.g., x <= y + z -> 0 <= y + z - x <= inf +# y + z >= x -> 0 <= y + z - x <= inf +# x >= y + z -> -inf <= y + z - x <= 0 +# y + z <= x -> -inf <= y + z - x <= 0 +# y + z == x -> y + z - x == 0 +# x == y + z -> y + z - x == 0 +# +# 2. If equal, move to side without a constant, e.g., y <= x + 2 -> -inf <= y - x <= 2 +# x + 2 >= y -> -inf <= y - x <= 2 +# x + 2 <= y -> 2 <= y - x <= inf +# y >= x + 2 -> 2 <= y - x <= inf +# y + 2 == x -> x - y == 2 +# x == y + 2 -> x - y == 2 +# +# 3. If both have constants, move to the 'left', e.g., x + 2 <= y + 3 -> -inf <= x - y <= 1 +# y + 3 >= x + 2 -> -inf <= x - y <= 1 +# x + 2 >= y + 3 -> -inf <= y - x <= -1 +# y + 3 <= x + 2 -> -inf <= y - x <= -1 +# x + 2 == y + 3 -> x - y == 1 +# y + 3 == x + 2 -> y - x == -1 +# Hence: +# 6 <= x + y <= 8 -> 6 <= x + y <= 8 #(1) +# 6 + x <= y <= 8 + x -> 6 <= y - x <= 8 #(2) +# 6 + x <= y + 2 <= 8 + x -> 4 <= y - x <= 6 #(3) +# x <= 6 <= x -> 6 <= x <= 6 #(1) +# x <= y + z <= x + 5 -> 0 <= y + z - x <= 5 #(1) class highs_linear_expression(object): - """Basic constraint builder for HiGHS""" - __slots__ = ['vars', 'vals', 'LHS', 'RHS', 'constant'] + """ + Basic constraint builder for HiGHS + """ + __slots__ = ['vars', 'vals', 'bounds', 'constant'] def __init__(self, other=None): - self.constant = 0 - self.LHS = -kHighsInf - self.RHS = kHighsInf + self.constant = None # constant is only valid when bounds are None + self.bounds = None # bounds are only valid when constant is None - if isinstance(other, highs_linear_expression): + if other is None: + self.vars = [] + self.vals = [] + + elif isinstance(other, highs_linear_expression): self.vars = list(other.vars) self.vals = list(other.vals) self.constant = other.constant - self.LHS = other.LHS - self.RHS = other.RHS + self.bounds = list(other.bounds) if other.bounds != None else None elif isinstance(other, highs_var): self.vars = [other.index] self.vals = [1.0] - else: + + elif isinstance(other, (int, float, Decimal)): self.vars = [] self.vals = [] + self.constant = float(other) - def __neg__(self): - return -1.0 * self + else: + raise Exception('Invalid type for highs_linear_expression') - # (LHS <= self <= RHS) <= (other.LHS <= other <= other.RHS) - def __le__(self, other): - if isinstance(other, highs_linear_expression): - if self.LHS != -kHighsInf and self.RHS != kHighsInf and len(other.vars) > 0 or other.LHS != -kHighsInf: - raise Exception('Cannot construct constraint with variables as bounds.') + def simplify(self): + """ + Simplifies the linear expression by combining duplicate variables. + """ + copy = highs_linear_expression() + copy.vars, copy.vals = (v.tolist() for v in self.unique_elements()) + copy.bounds = list(self.bounds) if self.bounds != None else None + copy.constant = self.constant + return copy - # move variables from other to self - self.vars.extend(other.vars) - self.vals.extend([-1.0 * v for v in other.vals]) - self.constant -= other.constant - self.RHS = 0 - return self + def copy(self): + """ + Creates a copy of the linear expression. + """ + return highs_linear_expression(self) - elif isinstance(other, highs_var): - return NotImplemented + def evaluate(self, values): + """ + Evaluates the linear expression given a solution array (values). + """ + result = sum(v * values[c] for c,v in zip(self.vars, self.vals)) + (self.constant or 0.0) + return result if self.bounds == None else (self.bounds[0] <= result <= self.bounds[1]) - elif isinstance(other, (int, float, Decimal)): - self.RHS = min(self.RHS, other) - return self + def __repr__(self): + # display duplicate variables + v = str.join(" ", [f"{c}_v{x}" for x,c in zip(self.vars,self.vals)]) + if self.bounds == None: + return f"{v}" + (f" {self.constant}" if self.constant != None else '') + elif self.bounds[0] == self.bounds[1]: + return f"{v} == {self.bounds[0] - (self.constant or 0.0)}" else: - return NotImplemented + return f"{self.bounds[0]} <= {v} <= {self.bounds[1]}" + + def __str__(self): + # display unique variables (values are totaled) + vars,vals = self.unique_elements() + v = str.join(" ", [f"{c}_v{x}" for x,c in zip(vars,vals)]) - # (LHS <= self <= RHS) == (other.LHS <= other <= other.RHS) + if self.bounds == None: + return f"{v}" + (f" {self.constant}" if self.constant != None else '') + elif self.bounds[0] == self.bounds[1]: + return f"{v} == {self.bounds[0] - (self.constant or 0.0)}" + else: + return f"{self.bounds[0]} <= {v} <= {self.bounds[1]}" + + # self != other + def __ne__(self, other): + if other == None: + return True + else: + raise Exception('Invalid comparison.') + + # self == other def __eq__(self, other): - if isinstance(other, highs_linear_expression): - if self.LHS != -kHighsInf and len(other.vars) > 0 or other.LHS != -kHighsInf: - raise Exception('Cannot construct constraint with variables as bounds.') + if self.bounds != None: + raise Exception('Bounds have already been set.') - # move variables from other to self - self.vars.extend(other.vars) - self.vals.extend([-1.0 * v for v in other.vals]) - self.constant -= other.constant - self.LHS = 0 - self.RHS = 0 - return self + # self == c + elif isinstance(other, (int, float, Decimal)): + copy = highs_linear_expression(self) + copy.bounds = [float(other) - (self.constant or 0.0), float(other) - (self.constant or 0.0)] + copy.constant = None + return copy + + # self == other + elif isinstance(other, highs_linear_expression): + if other.bounds != None: + raise Exception('Bounds have already been set.') + + copy = highs_linear_expression() + + # prefer most vars, constant, 'left' + if len(other.vars) > len(self.vars) or (len(other.vars) == len(self.vars) and other.constant == None and self.constant != None): + copy.vars = other.vars + self.vars + copy.vals = other.vals + [-v for v in self.vals] + copy.bounds = [(self.constant or 0.0) - (other.constant or 0.0), (self.constant or 0.0) - (other.constant or 0.0)] + else: + copy.vars = self.vars + other.vars + copy.vals = self.vals + [-v for v in other.vals] + copy.bounds = [(other.constant or 0.0) - (self.constant or 0.0), (other.constant or 0.0) - (self.constant or 0.0)] + + return copy + + # self == x + elif isinstance(other, highs_var): + copy = highs_linear_expression() + + if len(self.vars) == 0 or len(self.vars) == 1 and self.constant != None: + copy.vars = [other.index] + self.vars + copy.vals = [1.0] + [-v for v in self.vals] + copy.bounds = [(self.constant or 0.0), (self.constant or 0.0)] + else: + copy.vars = self.vars + [other.index] + copy.vals = self.vals + [-1.0] + copy.bounds = [-(self.constant or 0.0), -(self.constant or 0.0)] + + return copy + + # support expr == [lb, ub] --> lb <= expr <= ub + elif hasattr(other, "__getitem__") and hasattr(other, "__len__") and len(other) == 2: + if not (isinstance(other[0], (int, float, Decimal)) and isinstance(other[1], (int, float, Decimal))): + raise Exception('Provided bounds were not valid numbers.') + + copy = highs_linear_expression(self) + copy.bounds = [float(other[0]) - (copy.constant or 0.0), float(other[1]) - (copy.constant or 0.0)] + copy.constant = None + return copy + + else: + raise Exception('Unknown comparison.') + + # self <= other + def __le__(self, other): + if self.bounds != None: + raise Exception('Bounds have already been set.') + elif self.__is_active_chain(): + other = other if isinstance(other, highs_linear_expression) else highs_linear_expression(other) + order = self.__get_chain(other, False) # [self, LHS, inner, RHS, other], ignores None values + + # inner <= (self == RHS) <= other + # LHS <= (self == inner) <= other + if self.__is_equal_except_bounds(order[2]) == True: + return self.__compose_chain(*order[1:]) + + # self <= (other == inner) <= RHS + # self <= (other == LHS) <= inner + elif other.__is_equal_except_bounds(order[1]): + return self.__compose_chain(*order[:-1]) + + self.__reset_chain(self, other, None) + + # self <= c + if isinstance(other, (int, float, Decimal)): + copy = highs_linear_expression(self) + copy.bounds = [-kHighsInf, float(other) - (copy.constant or 0.0)] + copy.constant = None + return copy + + # self <= other + elif isinstance(other, highs_linear_expression): + if other.bounds == None: + copy = highs_linear_expression() + + # prefer most vars, constant, 'left' + if len(other.vars) > len(self.vars) or (len(other.vars) == len(self.vars) and other.constant == None and self.constant != None): + copy.vars = other.vars + self.vars + copy.vals = other.vals + [-v for v in self.vals] + copy.bounds = [(self.constant or 0.0) - (other.constant or 0.0), kHighsInf] + else: + copy.vars = self.vars + other.vars + copy.vals = self.vals + [-v for v in other.vals] + copy.bounds = [-kHighsInf, (other.constant or 0.0) - (self.constant or 0.0)] + + return copy + else: + raise Exception('Bounds have already been set.') + + # self <= x elif isinstance(other, highs_var): - return NotImplemented + copy = highs_linear_expression() - elif isinstance(other, (int, float, Decimal)): - if self.LHS != -kHighsInf or self.RHS != kHighsInf: - raise Exception('Logic error in constraint equality.') + if len(self.vars) == 0 or len(self.vars) == 1 and self.constant != None: + copy.vars = [other.index] + self.vars + copy.vals = [1.0] + [-v for v in self.vals] + copy.bounds = [(self.constant or 0.0), kHighsInf] + else: + copy.vars = self.vars + [other.index] + copy.vals = self.vals + [-1.0] + copy.bounds = [-kHighsInf, -(self.constant or 0.0)] - self.LHS = other - self.RHS = other - return self + return copy else: - return NotImplemented + raise Exception('Unknown comparison.') - # (other.LHS <= other <= other.RHS) <= (LHS <= self <= RHS) + # other <= self def __ge__(self, other): - if isinstance(other, highs_linear_expression): - return other <= self + if self.bounds != None: + raise Exception('Bounds have already been set.') + + elif self.__is_active_chain(): + other = other if isinstance(other, highs_linear_expression) else highs_linear_expression(other) + order = self.__get_chain(other, True) # [other, LHS, inner, RHS, self], ignores None values + + # other <= (self == LHS) <= inner + # other <= (self == inner) <= RHS + if self.__is_equal_except_bounds(order[1]) == True: + return self.__compose_chain(*order[:-1]) + + # LHS <= (other == inner) <= self + # inner <= (other == RHS) <= self + elif other.__is_equal_except_bounds(order[2]): + return self.__compose_chain(*order[1:]) + self.__reset_chain(None, other, self) + + # c <= self + if isinstance(other, (int, float, Decimal)): + copy = highs_linear_expression(self) + copy.bounds = [float(other) - (self.constant or 0.0), kHighsInf] + copy.constant = None + return copy + + # other <= self + elif isinstance(other, highs_linear_expression): + if other.bounds == None: + copy = highs_linear_expression() + + # prefer most vars, constant, 'left' + if len(self.vars) > len(other.vars) or (len(self.vars) == len(other.vars) and self.constant == None and other.constant != None): + copy.vars = self.vars + other.vars + copy.vals = self.vals + [-v for v in other.vals] + copy.bounds = [(other.constant or 0.0) - (self.constant or 0.0), kHighsInf] + else: + copy.vars = other.vars + self.vars + copy.vals = other.vals + [-v for v in self.vals] + copy.bounds = [-kHighsInf, (self.constant or 0.0) - (other.constant or 0.0)] + + return copy + else: + raise Exception('Bounds have already been set.') + + # x <= self elif isinstance(other, highs_var): - return NotImplemented + copy = highs_linear_expression() - elif isinstance(other, (int, float, Decimal)): - self.LHS = max(self.LHS, other) - return self + if len(self.vars) > 1: + copy.vars = self.vars + [other.index] + copy.vals = self.vals + [-1.0] + copy.bounds = [-(self.constant or 0.0), kHighsInf] + else: + copy.vars = [other.index] + self.vars + copy.vals = [1.0] + [-v for v in self.vals] + copy.bounds = [-kHighsInf, (self.constant or 0.0)] + + return copy else: - return NotImplemented + raise Exception('Unknown comparison.') def __radd__(self, other): return self + other # (LHS <= self <= RHS) + (LHS <= other <= RHS) def __add__(self, other): - if isinstance(other, highs_linear_expression): + copy = highs_linear_expression(self) + copy += other + return copy + + def __neg__(self): + return -1.0 * self + + def __rmul__(self, other): + return self * other + + def __mul__(self, other): + copy = highs_linear_expression(self) + copy *= other + return copy + + # other - self + def __rsub__(self, other): + copy = highs_linear_expression(other) + copy -= self + return copy + + def __sub__(self, other): + copy = highs_linear_expression(self) + copy -= other + return copy + + def unique_elements(self): + """ + Collects unique variables and sums their corresponding values. Keeps all values (including zeros). + """ + # sort by groups for fast unique + groups = np.asarray(self.vars, dtype=np.int32) + order = np.argsort(groups, kind='stable') + groups = groups[order] + + # get unique groups + index = np.ones(len(groups), dtype=bool) + index[:-1] = groups[1:] != groups[:-1] + + if index.all(): + values = np.asarray(self.vals, dtype=np.float64) + values = values[order] + return groups, values + else: + values = np.array(self.vals) + values = np.cumsum(values[order]) + values = values[index] + groups = groups[index] + + # calculate the correct sum (diff of cumsum) + values[1:] = values[1:] - values[:-1] + return groups, values + + def reduced_elements(self): + """ + Similar to unique_elements, except keeps only non-zero values + """ + vx, vl = self.unique_elements() + zx = np.nonzero(vl) + return vx[zx], vl[zx] + + ## + ## mutable functions + ## + + # (LHS <= self <= RHS) += (LHS <= other <= RHS) + def __iadd__(self, other): + if isinstance(other, highs_var): + self.vars.append(other.index) + self.vals.append(1.0) + return self + + elif isinstance(other, highs_linear_expression): + if (self.constant != None and other.bounds != None or self.bounds != None and other.constant != None): + raise Exception('''Cannot add a bounded constraint to a constraint with a constant, i.e., (lb <= expr1 <= ub) + (expr2 + c). + Unsure of your intent. Did you want: lb + c <= expr1 + expr2 <= ub + c? Try: (lb <= expr1 <= ub) + (expr2 == c) instead.''') + self.vars.extend(other.vars) self.vals.extend(other.vals) - self.constant += other.constant - self.LHS = max(self.LHS, other.LHS) - self.RHS = min(self.RHS, other.RHS) + + if self.constant != None or other.constant != None: + self.constant = (self.constant or 0.0) + (other.constant or 0.0) + + # (l1 <= expr1 <= u1) + (l2 <= expr2 <= u2) --> l1 + l2 <= expr1 + expr2 <= u1 + u2 + if self.bounds != None and other.bounds != None: + self.bounds[0] += other.bounds[0] + self.bounds[1] += other.bounds[1] + + # (expr1) + (lb <= expr2 <= ub) --> lb <= expr1 + expr2 <= ub + elif self.bounds == None and other.bounds != None: + self.bounds = list(other.bounds) + return self - elif isinstance(other, highs_var): + elif isinstance(other, (int, float, Decimal)): + if self.bounds != None: + raise Exception('''Cannot add a constant to a bounded constraint, i.e., (lb <= expr <= ub) + c. + Unsure of your intent. Did you want: lb + c <= expr <= ub + c? Try: (lb <= expr <= ub) + (highs_linear_expression() == c) instead.''') + + self.constant = float(other) + (self.constant or 0.0) + return self + + else: + raise Exception('Unexpected parameters.') + + def __isub__(self, other): + if isinstance(other, highs_var): self.vars.append(other.index) - self.vals.append(1.0) + self.vals.append(-1.0) + return self + + elif isinstance(other, highs_linear_expression): + if (self.constant != None and other.bounds != None or self.bounds != None and other.constant != None): + raise Exception('''Cannot subtract a bounded constraint to a constraint with a constant, i.e., (lb <= expr1 <= ub) - (expr2 + c). + Unsure of your intent. Did you want: lb - c <= expr1 - expr2 <= ub - c? Try: (lb <= expr1 <= ub) - (expr2 == c) instead.''') + + self.vars.extend(other.vars) + self.vals.extend([-v for v in other.vals]) + + if self.constant != None or other.constant != None: + self.constant = (self.constant or 0.0) - (other.constant or 0.0) + + # (l1 <= expr1 <= u1) - (l2 <= expr2 <= u2) --> l1 - u2 <= expr1 - expr2 <= u1 - l2 + if self.bounds != None and other.bounds != None: + self.bounds[0] -= other.bounds[1] + self.bounds[1] -= other.bounds[0] + + # expr1 - (lb <= expr2 <= ub) --> -ub <= expr1 - expr2 <= -lb + elif self.bounds == None and other.bounds != None: + self.bounds = [-other.bounds[1], -other.bounds[0]] + return self elif isinstance(other, (int, float, Decimal)): - self.constant += other + if self.bounds != None: + raise Exception('''Cannot subtract a constant to a bounded constraint, i.e., (lb <= expr <= ub) - c. + Unsure of your intent. Did you want: lb - c <= expr <= ub - c? Try: (lb <= expr <= ub) - (highs_linear_expression() == c) instead.''') + + self.constant = (self.constant or 0.0) - other return self else: - return NotImplemented + raise Exception('Unexpected parameters.') - def __rmul__(self, other): - return self * other + def __imul__(self, other): + if isinstance(other, (int, float, Decimal)): + scale = float(other) - def __mul__(self, other): - result = highs_linear_expression(self) + # other is a constant expression, so treat as a scalar + elif isinstance(other, highs_linear_expression) and other.vars == [] and other.constant != None: + scale = float(other.constant) + else: + scale = None + + if scale != None: + self.vals = [scale * v for v in self.vals] + + if self.constant != None: + self.constant *= scale + + if self.bounds != None: + # negative scale reverses bounds + if scale >= 0: + self.bounds = [scale * self.bounds[0], scale * self.bounds[1]] + else: + self.bounds = [scale * self.bounds[1], scale * self.bounds[0]] + + return self - if isinstance(other, (int, float, Decimal)): - result.vals = [float(other) * v for v in self.vals] - result.constant *= float(other) - return result elif isinstance(other, highs_var): raise Exception('Only linear expressions are allowed.') + + # self only has a constant, so treat as a scalar + elif self.vars == [] and self.constant != None: + scale = float(self.constant) + + self.vars = other.vars + self.vals = [scale * v for v in other.vals] + + if other.constant != None: + self.constant = other.constant * scale + else: + self.constant = None + + if other.bounds != None: + # negative scale reverses bounds + if scale >= 0: + self.bounds = [scale * other.bounds[0], scale * other.bounds[1]] + else: + self.bounds = [scale * other.bounds[1], scale * other.bounds[0]] + + return self else: - return NotImplemented + raise Exception('Unexpected parameters.') - def __rsub__(self, other): - return other + -1.0 * self - def __sub__(self, other): - if isinstance(other, highs_linear_expression): - return self + (-1.0 * other) - elif isinstance(other, highs_var): - return self + (-1.0 * highs_linear_expression(other)) - elif isinstance(other, (int, float, Decimal)): - return self + (-1.0 * other) + # The following is needed to support chained comparison, i.e., lb <= expr <= ub. This is interpreted + # as '__bool__(lb <= expr) and (expr <= ub)'; returning (expr <= ub), since __bool__(lb <= expr) == True. + # + # We essentially want to "rewrite" this as '(lb <= expr) <= ub', while keeping the expr instance immutable. + # As a slight hack, we can use a shared (thread local) object to keep track of the chain. + # + # Whenever we perform an inequality, we first check if the current expression is part of a chain. + # This inner '__chain' is set by __bool__(lb <= expr) and is reset after the inequality is evaluated. + # + # Two potential issues: + # 1. It is possible to manually construct this sequence, e.g., + # tmp = x0 + x1 + # bool(tmp <= 10) + # print(5 <= tmp) # outputs: 5 <= 1.0_v0 + 1.0_v1 <= 10 + # print(5 <= tmp) # outputs: 5 <= 1.0_v0 + 1.0_v1 <= inf : chain is broken + # + # Note that: + # bool(tmp <= 10) + # tmp += x0 + x1 # changes tmp, so the chain is broken + # print(5 <= tmp) # outputs: 5 <= 2.0_v0 + 2.0_v1 <= inf + # + # 2. The chain might "break" if run within a debugger (on same thread), i.e., "watched debugger expressions" + # that evaluate any variant of highs_linear_expression inequalities. + # + # I believe these issues are low risk, the approach thread safe, and the performance/overhead is minimal. + # + __chain = local() + + # capture the chain + def __bool__(self): + highs_linear_expression.__chain.check = self + + # take copies of the chain to avoid issues with mutable expressions + LHS = getattr(highs_linear_expression.__chain, 'left', None) + EXR = getattr(highs_linear_expression.__chain, 'inner', None) + RHS = getattr(highs_linear_expression.__chain, 'right', None) + + highs_linear_expression.__chain.left = highs_linear_expression(LHS) if LHS != None else None + highs_linear_expression.__chain.inner = highs_linear_expression(EXR) if EXR != None else None + highs_linear_expression.__chain.right = highs_linear_expression(RHS) if RHS != None else None + return True + + def __is_equal_except_bounds(self, other): + return self.vars == other.vars and self.vals == other.vals and self.constant == other.constant + + def __is_active_chain(self): + return getattr(highs_linear_expression.__chain, 'check', None) is not None + + def __reset_chain(self, LHS=None, inner=None, RHS=None): + highs_linear_expression.__chain.check = None + highs_linear_expression.__chain.left = LHS + highs_linear_expression.__chain.inner = inner + highs_linear_expression.__chain.right = RHS + + def __get_chain(self, other, is_ge_than): + LHS = getattr(highs_linear_expression.__chain, 'left', None) + RHS = getattr(highs_linear_expression.__chain, 'right', None) + inner = getattr(highs_linear_expression.__chain, 'inner', None) + + # assume (LHS is None) ^ (RHS is None) == 1, i.e., only LHS or RHS is set + assert((LHS is None) ^ (RHS is None) == 1) + + order = np.asarray([other, LHS, inner, RHS, self]) + order = order[order != None] + + if is_ge_than == False: # swap order for: self <= other + order[0], order[-1] = order[-1], order[0] + + return order + + # left <= self <= right + def __compose_chain(self, left, inner, right): + self.__reset_chain() + assert (isinstance(inner, highs_linear_expression) == False or inner.bounds == None), 'Bounds already set in chain comparison.' + + # check to see if we have a valid chain, i.e., left.vars "==" right.vars + # we can assume that left and right are both highs_linear_expression + LHS_vars, LHS_vals = left.reduced_elements() + RHS_vars, RHS_vals = right.reduced_elements() + + if np.array_equal(LHS_vars, RHS_vars) == False or np.array_equal(LHS_vals, RHS_vals) == False: + raise Exception('Mismatched variables in chain comparison.') + + if len(LHS_vars) > len(inner.vars): + copy = highs_linear_expression() + copy.vars = LHS_vars.tolist() + inner.vars + copy.vals = LHS_vals.tolist() + [-v for v in inner.vals] + copy.bounds = [(inner.constant or 0.0) - (right.constant or 0.0), (inner.constant or 0.0) - (left.constant or 0.0)] else: - return NotImplemented + copy = highs_linear_expression(inner) + copy.vars.extend(LHS_vars) + copy.vals.extend([-v for v in LHS_vals]) + copy.bounds = [(left.constant or 0.0) - (copy.constant or 0.0), (right.constant or 0.0) - (copy.constant or 0.0)] + copy.constant = None + + return copy + +def qsum(items, initial=None): + """Performs a faster sum for highs_linear_expressions. + + Args: + items: A collection of highs_linear_expressions or highs_vars to be summed. + """ + return Highs.qsum(items, initial) diff --git a/tests/test_highspy.py b/tests/test_highspy.py index 8d8a27174f..2a38b9e719 100644 --- a/tests/test_highspy.py +++ b/tests/test_highspy.py @@ -1,17 +1,24 @@ import tempfile import unittest import highspy +from highspy.highs import highs_linear_expression, qsum import numpy as np -from io import StringIO from sys import platform +import signal class TestHighsPy(unittest.TestCase): + def assertEqualExpr(self, expr, vars, vals, constant=None, bounds=None): + self.assertEqual(list(map(int, expr.vars)), list(map(int, vars)), 'variable index') + self.assertEqual(expr.vals, vals, 'variable values') + self.assertEqual(expr.constant, constant, 'constant') + self.assertEqual(expr.bounds, bounds, 'bounds') + def get_basic_model(self): """ min y s.t. -x + y >= 2 - x + y >= 0 + x + y >= 0 """ inf = highspy.kHighsInf h = highspy.Highs() @@ -432,8 +439,23 @@ def test_ranging(self): self.assertEqual(ranging.row_bound_up.value_[1], inf); self.assertEqual(ranging.row_bound_up.objective_[1], inf); - # Temporarily disable modelling tests until language is included properly. - + # this catches error in our highs_bindings (pybind11) when passed arrays are not contiguous + def test_numpy_slice(self): + h = highspy.Highs() + + N = 10 + zero, ones = np.zeros(N), np.ones(N) + h.addCols(N, zero, zero, ones, 0, [], [], []) + + x = np.arange(N, dtype=np.int32) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + tmp = x[1::2] # [1, 3, 5, 7, 9] + + h.addRow(1, 1, len(tmp), tmp, ones) + + # needs to be rowwise for test to work correctly + self.assertEqual(h.getLp().a_matrix_.format_, highspy.highs.MatrixFormat.kRowwise) + self.assertEqual(h.getLp().a_matrix_.index_, list(tmp)) + def test_constraint_removal(self): h = highspy.Highs() x = h.addVariable(lb=-highspy.kHighsInf) @@ -452,15 +474,58 @@ def test_infeasible_model(self): x = h.addVariable() y = h.addVariable() - h.addConstr(x + y == 3) - h.addConstr(x + y == 1) - + c1 = h.addConstr(x + y == 3) + c2 = h.addConstr(x + y == 1) + status = h.minimize(10*x + 15*y) self.assertEqual(status, highspy.HighsStatus.kOk) status = h.getModelStatus() self.assertEqual(status, highspy.HighsModelStatus.kInfeasible) + + # change the model to be feasible + h.chgCoeff(c1, y, 3) + + status = h.run() + self.assertEqual(status, highspy.HighsStatus.kOk) + + status = h.getModelStatus() + self.assertEqual(status, highspy.HighsModelStatus.kOptimal) + + + def test_simple_basics_builder(self): + h = highspy.Highs() + h.silent() + + x, y = h.addVariables(2, lb=-highspy.kHighsInf) + c = h.addConstrs(-x + y >= 2, + x + y >= 0) + h.minimize(y) + self.assertAlmostEqual(list(h.val([x,y])), [-1, 1]) + self.assertAlmostEqual(list(h.variableValue([x,y])), [-1, 1]) + self.assertAlmostEqual(list(h.variableValues([x,y])), [-1, 1]) + self.assertAlmostEqual(list(h.allVariableValues()), [-1, 1]) + + # -x + y >= 3 + h.changeRowBounds(0, 3, highspy.kHighsInf) + h.run() + self.assertAlmostEqual(list(h.val([x,y])), [-1.5, 1.5]) + + # make y integer + h.setInteger(y) + h.run() + self.assertAlmostEqual(list(h.val([x,y])), [-1, 2]) + + # delete the first constraint and add a new one + h.removeConstr(c[0], c) + self.assertEqual(list(map(int, c)), [-1, 0]) + + h.addConstr(-x + y >= 0) + h.run() + self.assertAlmostEqual(list(h.val([x, y])), [0,0]) + + def test_basics_builder(self): h = highspy.Highs() h.setOptionValue('output_flag', False) @@ -488,6 +553,38 @@ def test_basics_builder(self): self.assertAlmostEqual(h.val(x), -1.5) self.assertAlmostEqual(h.val(y), 1.5) + sol = h.getSolution() + self.assertAlmostEqual(h.variableDual(x), sol.col_dual[0]) + + self.assertAlmostEqual(h.variableDuals(x), sol.col_dual[0]) + self.assertAlmostEqual(list(h.variableDuals([x,y])), [sol.col_dual[0], sol.col_dual[1]]) + self.assertAlmostEqual(h.variableDuals({'x': x, 'y': y}), {'x': sol.col_dual[0], 'y': sol.col_dual[1]}) + self.assertAlmostEqual(list(h.variableDuals(np.asarray([x,y]))), [sol.col_dual[0], sol.col_dual[1]]) + + self.assertAlmostEqual(h.allVariableDuals(), sol.col_dual) + + c1, c2 = h.getConstrs() + self.assertAlmostEqual(h.constrValue(c1), sol.row_value[0]) + + self.assertAlmostEqual(h.constrValues(c1), sol.row_value[0]) + self.assertAlmostEqual(list(h.constrValues([c1,c2])), sol.row_value) + self.assertAlmostEqual(list(h.constrValues([c2,c1])), sol.row_value[::-1]) # order matters + self.assertAlmostEqual(h.constrValues({'c1': c1, 'c2': c2}), {'c1': sol.row_value[0], 'c2': sol.row_value[1]}) + self.assertAlmostEqual(list(h.constrValues(np.asarray([c1,c2]))), sol.row_value) + + self.assertAlmostEqual(h.allConstrValues(), sol.row_value) + + self.assertAlmostEqual(h.constrDual(c1), sol.row_dual[0]) + + self.assertAlmostEqual(h.constrDuals(c1), sol.row_dual[0]) + self.assertAlmostEqual(list(h.constrDuals([c1,c2])), sol.row_dual) + self.assertAlmostEqual(list(h.constrDuals([c2,c1])), sol.row_dual[::-1]) # order matters + self.assertAlmostEqual(h.constrDuals({'c1': c1, 'c2': c2}), {'c1': sol.row_dual[0], 'c2': sol.row_dual[1]}) + self.assertAlmostEqual(list(h.constrDuals(np.asarray([c1,c2]))), sol.row_dual) + + self.assertAlmostEqual(h.allConstrDuals(), sol.row_dual) + + # now make y integer h.changeColsIntegrality(1, np.array([1]), np.array([highspy.HighsVarType.kInteger])) h.run() @@ -544,16 +641,23 @@ def test_addVariable(self): h = highspy.Highs() h.addVariable() self.assertEqual(h.numVariables, 1) + + # exception + self.assertRaises(Exception, lambda: h.addVariable(lb = h.inf)) + self.assertRaises(Exception, lambda: h.addVariables(2, lb=h.inf)) def test_addConstr(self): h = highspy.Highs() x = h.addVariable() y = h.addVariable() - h.addConstr(2*x + 3*y <= 10) + c1 = h.addConstr(2*x + 3*y <= 10, name='c1') self.assertEqual(h.numVariables, 2) self.assertEqual(h.numConstrs, 1) self.assertEqual(h.getNumNz(), 2) + self.assertEqual(c1.name, 'c1') + c1.name = 'c2' + self.assertEqual(c1.name, 'c2') lp = h.getLp() self.assertAlmostEqual(lp.row_lower_[0], -highspy.kHighsInf) @@ -575,6 +679,7 @@ def test_removeConstr(self): h.removeConstr(c) self.assertEqual(h.numVariables, 2) self.assertEqual(h.numConstrs, 0) + self.assertRaises(Exception, lambda: c.name('c')) def test_val(self): h = highspy.Highs() @@ -590,6 +695,16 @@ def test_val(self): self.assertAlmostEqual(vals[0], 5) self.assertAlmostEqual(vals[1], 0) + # test linear expr + self.assertAlmostEqual(h.val(2*x[0] + 3*x[1]), 10) + self.assertAlmostEqual(h.val(1*x[0] + 3*x[1]), 5) + + self.assertAlmostEqual(h.val(1*x[0] + 3*x[1] <= 4), False) + self.assertAlmostEqual(h.val(1*x[0] + 3*x[1] == 5), True) + self.assertAlmostEqual(h.val(1*x[0] + 3*x[1] >= 6), False) + + + def test_var_name(self): h = highspy.Highs() @@ -613,6 +728,15 @@ def test_var_name(self): h.passColName(0, 'a') self.assertEqual(h.getLp().col_names_[0], 'a') self.assertEqual(x.name, 'a') + self.assertEqual(h.variableName(x), 'a') + self.assertRaises(Exception, lambda: h.variableName(1)) + self.assertEqual(h.variableNames([x]), ['a']) + self.assertEqual(h.variableNames({'key': x}), {'key': 'a'}) + self.assertEqual(h.allVariableNames(), ['a']) + + y = h.addVariable() + h.deleteVariable(y) + self.assertRaises(Exception, lambda: y.name) def test_binary(self): @@ -652,6 +776,22 @@ def test_integer(self): self.assertAlmostEqual(vals[0], 5) self.assertAlmostEqual(vals[1], 0.2) + y = h.addIntegrals(2) + self.assertEqual(h.getLp().integrality_[2], highspy.HighsVarType.kInteger) + self.assertEqual(h.getLp().integrality_[3], highspy.HighsVarType.kInteger) + + h.setContinuous(y[0]) + self.assertEqual(h.getLp().integrality_[2], highspy.HighsVarType.kContinuous) + self.assertEqual(h.getLp().integrality_[3], highspy.HighsVarType.kInteger) + + h.setInteger(y) + self.assertEqual(h.getLp().integrality_[2], highspy.HighsVarType.kInteger) + self.assertEqual(h.getLp().integrality_[3], highspy.HighsVarType.kInteger) + + h.setContinuous(y) + self.assertEqual(h.getLp().integrality_[2], highspy.HighsVarType.kContinuous) + self.assertEqual(h.getLp().integrality_[3], highspy.HighsVarType.kContinuous) + def test_objective(self): h = highspy.Highs() h.setOptionValue('output_flag', False) @@ -668,80 +808,69 @@ def test_constraint_builder(self): # -inf <= 2x + 3y <= inf c1 = 2*x + 3*y - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (-highspy.kHighsInf, highspy.kHighsInf, 0)) + self.assertEqualExpr(c1, [x, y], [2,3]) # -inf <= 2x + 3y <= 2x c1 = 2*x + 3*y <= 2*x - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (-highspy.kHighsInf, 0, 0)) + self.assertEqualExpr(c1, [x, y, x], [2,3,-2], None, [-highspy.kHighsInf, 0]) # -inf <= 2x + 3y <= 2x c1 = 2*x >= 2*x + 3*y - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (-highspy.kHighsInf, 0, 0)) - - # max{1,4} <= 2x + 3y <= inf - c1 = 1 <= (4 <= 2*x + 3*y) - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (4, highspy.kHighsInf, 0)) - - # -inf <= 2x + 3y <= min{1,4} - c1 = 2 >= (4 >= 2*x + 3*y) - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (-highspy.kHighsInf, 2, 0)) - c1 = 2*x + 3*y <= (2 <= 4) - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (-highspy.kHighsInf, True, 0)) - c1 = (2*x + 3*y <= 2) <= 4 - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (-highspy.kHighsInf, 2, 0)) + self.assertEqualExpr(c1, [x, y, x], [2,3,-2], None, [-highspy.kHighsInf, 0]) - # 1 <= 2x + 3y <= 5 - c1 = (1 <= 2*x + 3*y) <= 5 - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (1, 5, 0)) - - # 1 <= 2x + 3y <= 5 - c1 = 1 <= (2*x + 3*y <= 5) - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (1, 5, 0)) + # failure add constraint without inequality + self.assertRaises(Exception, lambda: h.addConstr(x + 3*y)) + self.assertRaises(Exception, lambda: h.addConstrs(x == 1, x + 3*y)) + self.assertRaises(Exception, lambda: h.addConstrs({'a': x == 1, 'b': x + 3*y})) + + # ensure model is rolled back on error + self.assertEqual(h.numConstrs, 0) + self.assertRaises(Exception, lambda: h.addConstrs([x == 1]*100 + [x + 3*y])) + self.assertEqual(h.numConstrs, 0) - # 1 <= 2x + 3y <= 5 - c1 = 1 <= (5 >= 2*x + 3*y) - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (1, 5, 0)) - # 1 <= 2x + 3y <= 5 - c1 = (5 >= 2*x + 3*y) >= 1 - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (1, 5, 0)) + # failure - bounds already set + self.assertRaises(Exception, lambda: 1 <= (4 <= 2*x + 3*y)) + self.assertRaises(Exception, lambda: 2 >= (4 >= 2*x + 3*y)) + self.assertRaises(Exception, lambda: (2*x + 3*y <= 2) <= 4) + self.assertRaises(Exception, lambda: (1 <= 2*x + 3*y) <= 5) + self.assertRaises(Exception, lambda: 1 <= (2*x + 3*y <= 5)) + self.assertRaises(Exception, lambda: 1 <= (5 >= 2*x + 3*y)) + self.assertRaises(Exception, lambda: (5 >= 2*x + 3*y) >= 1) + self.assertRaises(Exception, lambda: 5 >= (2*x + 3*y >= 1)) - # 1 <= 2x + 3y <= 5 - c1 = 5 >= (2*x + 3*y >= 1) - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (1, 5, 0)) + c1 = 2*x + 3*y <= (2 <= 4) # 2x + 3y <= float(True) + self.assertEqualExpr(c1, [x, y], [2,3], None, [-highspy.kHighsInf, 1]) # failure, non-linear terms - self.assertRaises(Exception, lambda: 2*x*3*y, None) + self.assertRaises(Exception, lambda: 2*x*3*y) # failure, order matters when having variables on both sides of inequality - # -inf <= 4*x - t <= min{0, 5} - c1 = (4*x <= 2*x + 3*y) <= 5 - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (-highspy.kHighsInf, 0, 0)) - - #4*x <= (2*x + 3*y <= 5) - self.assertRaises(Exception, lambda: 4*x <= (2*x + 3*y <= 5), None) + self.assertRaises(Exception, lambda: (4*x <= 2*x + 3*y) <= 5) + self.assertRaises(Exception, lambda: 4*x <= (2*x + 3*y <= 5)) + self.assertRaises(Exception, lambda: (4*x <= 2*x + 3*y) <= 5*x) - #(4*x <= 2*x + 3*y) <= 5*x - self.assertRaises(Exception, lambda: (4*x <= 2*x + 3*y) <= 5*x, None) + c1 = 5*x <= 2*x + 3*y <= 5*x + self.assertEqualExpr(c1, [x, y, x], [2,3,-5], None, [0, 0]) # test various combinations with different inequalities - self.assertRaises(Exception, lambda: (2*x + 3*y == 3*y) == 3, None) - self.assertRaises(Exception, lambda: 2*x + 3*y == (3*y == 3), None) - self.assertRaises(Exception, lambda: 2*x + 3*y == (3*y <= 3), None) - self.assertRaises(Exception, lambda: 2*x + 3*y == (3*y >= 3), None) + self.assertRaises(Exception, lambda: (2*x + 3*y == 3*y) == 3) + self.assertRaises(Exception, lambda: 2*x + 3*y == (3*y == 3)) + self.assertRaises(Exception, lambda: 2*x + 3*y == (3*y <= 3)) + self.assertRaises(Exception, lambda: 2*x + 3*y == (3*y >= 3)) c1 = 2*x + 3*y == x - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (0, 0, 0)) + self.assertEqualExpr(c1, [x, y, x], [2,3,-1], None, [0, 0]) c1 = 2*x + 3*y == 5 - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (5, 5, 0)) + self.assertEqualExpr(c1, [x, y], [2,3], None, [5,5]) c1 = 5 == 2*x + 3*y - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (5, 5, 0)) + self.assertEqualExpr(c1, [x, y], [2,3], None, [5,5]) # 2*x + 3*y == 4.5 c1 = 2*x + 3*y + 0.5 == 5 - self.assertAlmostEqual((c1.LHS, c1.RHS, c1.constant), (5, 5, 0.5)) + self.assertEqualExpr(c1, [x, y], [2,3], None, [4.5,4.5]) h.addConstr(c1) self.assertAlmostEqual((h.getLp().row_lower_[0], h.getLp().row_upper_[0]), (4.5, 4.5)) @@ -753,15 +882,15 @@ def test_add_multiple_variables(self): # test multiple dimensions h = highspy.Highs() - x = h.addVariables(2,3,4) + x = h.addVariables(2,3,4, out_array=False) self.assertEqual(h.numVariables, 2*3*4) self.assertEqual(isinstance(x, dict), True) # test multiple dimensions array h = highspy.Highs() - x = h.addVariables(2,3,4, out_array=True) + x = h.addVariables(2,3,4) self.assertEqual(h.numVariables, 2*3*4) - self.assertEqual(isinstance(x, list), True) + self.assertEqual(x.shape, (2,3,4)) # test binary variables with objective and names h = highspy.Highs() @@ -909,3 +1038,1017 @@ def test_read_basis(self): h1.writeBasis(f.name) h2.readBasis(f.name) self.assertEqual(h2.getBasis().col_status[0], expected_status_after) + + def test_solve(self): + """Test the solve method to ensure it runs the solver.""" + h = highspy.Highs() + h.silent() + x = h.addBinary(obj=1) + h.setMaximize() + h.solve() + self.assertEqual(h.getSolution().col_value[0], 1) + self.assertEqual(h.getObjectiveSense()[1], highspy.highs.ObjSense.kMaximize) + + h.setMinimize() + h.optimize() + self.assertEqual(h.getSolution().col_value[0], 0) + self.assertEqual(h.getObjectiveSense()[1], highspy.highs.ObjSense.kMinimize) + + + def test_minimize(self): + """Test the minimize method with and without an objective.""" + h = highspy.Highs() + h.silent() + x,y,z = h.addBinaries(3, obj = -1) + h.minimize() + self.assertEqual(list(h.val([x, y, z])), [1, 1, 1]) + + h.minimize(-x) + self.assertEqual(h.val(x), 1) + + h.minimize(x) + self.assertEqual(h.val(x), 0) + + h.minimize(x - y) + self.assertEqual(h.val(x), 0) + self.assertEqual(h.val(y), 1) + + self.assertRaises(Exception, h.minimize, x - y <= 5) + self.assertRaises(Exception, h.minimize, 0 <= x - y <= 5) + self.assertRaises(Exception, h.minimize, 4 <= x - y) + self.assertRaises(Exception, h.minimize, 4) + + + def test_maximize(self): + """Test the maximize method with and without an objective.""" + h = highspy.Highs() + h.silent() + x,y,z = h.addBinaries(3, obj = 1) + h.maximize() + self.assertEqual(list(h.val([x, y, z])), [1, 1, 1]) + + h.maximize(x) + self.assertEqual(h.val(x), 1) + + h.maximize(-x) + self.assertEqual(h.val(x), 0) + + h.maximize(y - x) + self.assertEqual(h.val(x), 0) + self.assertEqual(h.val(y), 1) + + self.assertRaises(Exception, h.maximize, x - y <= 5) + self.assertRaises(Exception, h.maximize, 0 <= x - y <= 5) + self.assertRaises(Exception, h.maximize, 4 <= x - y) + self.assertRaises(Exception, h.maximize, 4) + + def test_get_expr(self): + h = self.get_basic_model() + + expr = h.getExpr(0) # -x + y >= 2 + self.assertEqualExpr(expr, [0, 1], [-1, 1], None, [2, highspy.kHighsInf]) + + expr = h.getExpr(1) # x + y >= 0 + self.assertEqualExpr(expr, [0, 1], [1, 1], None, [0, highspy.kHighsInf]) + + c = h.getConstrs() + self.assertEqualExpr(c[0].expr(), [0, 1], [-1, 1], None, [2, highspy.kHighsInf]) + self.assertEqualExpr(c[1].expr(), [0, 1], [ 1, 1], None, [0, highspy.kHighsInf]) + + self.assertRaises(Exception, lambda: h.getExpr(2)) + + def test_add_variables(self): + """Test adding multiple variables to the model.""" + h = highspy.Highs() + + var = h.addVariables() + self.assertEqual(var, None) + + keys = ['a', 'b', 'c'] + v = h.addVariables(keys) + self.assertTrue(isinstance(v, dict)) + self.assertEqual(int(v['a']), 0) + + h.addConstr(v['a'] + v['b'] + v['c'] == 1) + h.maximize(v['a']) + self.assertEqual(h.val(v), {'a': 1.0, 'b': 0, 'c': 0}) + self.assertEqual(list(map(int, h.getVariables())), [0,1,2]) + + + def test_delete_variable(self): + h = highspy.Highs() + + keys = ['a', 'b', 'c'] + D = h.addVariables(keys) + X = h.addVariables(5) + + self.assertEqual({k: int(v) for k,v in D.items()}, {'a': 0, 'b': 1, 'c': 2}) + self.assertEqual(list(map(int, X)), [3,4,5,6, 7]) + + # delete variable and update collections + h.deleteVariable(D['b'], D, X) + + self.assertEqual(h.numVariables, 7) + self.assertEqual({k: int(v) for k,v in D.items()}, {'a': 0, 'b': -1, 'c': 1}) + self.assertEqual(list(map(int, X)), [2,3,4,5,6]) + + # delete variable and update collections + h.deleteVariable(X[3], D, X) + + self.assertEqual(h.numVariables, 6) + self.assertEqual({k: int(v) for k,v in D.items()}, {'a': 0, 'b': -1, 'c': 1}) + self.assertEqual(list(map(int, X)), [2,3,4,-1,5]) + + # delete variable and update collections + h.deleteVariable(X[2], D, *X) + + self.assertEqual(h.numVariables, 5) + self.assertEqual({k: int(v) for k,v in D.items()}, {'a': 0, 'b': -1, 'c': 1}) + self.assertEqual(list(map(int, X)), [2,3,-1,-1,4]) + + + def test_remove_constraint(self): + h = highspy.Highs() + + keys = ['a', 'b', 'c'] + D = h.addVariables(keys) + X = h.addVariables(5) + + c1 = h.addConstr(D['a'] + D['b'] + D['c'] == 1) + cX = h.addConstrs((x >= 1 for x in X)) + c2 = h.addConstr(qsum(X) == 1) + c3 = h.addConstr(qsum(D.values()) == 1) + hash_test = {c1: 'c1', c2: 'c2', c3: 'c3'} + + self.assertEqual(h.numConstrs, 8) + self.assertEqual([int(c1)] + list(map(int, cX)) + [int(c2), int(c3)], [0,1,2,3,4,5,6,7]) + + # delete constr and update collections + h.removeConstr(cX[2], c1, cX, c2, c3) + + self.assertEqual(h.numConstrs, 7) + self.assertEqual([int(c1)] + list(map(int, cX)) + [int(c2), int(c3)], [0,1,2,-1,3,4,5,6]) + + # delete variable and update collections + h.removeConstr(c1, c1, cX) + + self.assertEqual(h.numConstrs, 6) + self.assertEqual([int(c1)] + list(map(int, cX)) + [int(c2), int(c3)], [-1,0,1,-1,2,3,5,6]) + + # add dictionary of constraints with highs_var keys + cM = h.addConstrs({x: x >= 1 for x in X}) + self.assertEqual(h.numConstrs, 11) + self.assertEqual([int(c1)] + list(map(int, cX)) + [int(c2), int(c3)], [-1,0,1,-1,2,3,5,6]) + self.assertEqual({int(k): int(c) for k,c in cM.items()}, {3: 6, 4: 7, 5: 8, 6: 9, 7: 10}) + + # add dictionary of constraints with string keys + cD = h.addConstrs({k: x >= 1 for k,x in D.items()}) + self.assertEqual(h.numConstrs, 14) + self.assertEqual([int(c1)] + list(map(int, cX)) + [int(c2), int(c3)], [-1,0,1,-1,2,3,5,6]) + self.assertEqual({int(k): int(c) for k,c in cM.items()}, {3: 6, 4: 7, 5: 8, 6: 9, 7: 10}) + self.assertEqual({k: int(c) for k,c in cD.items()}, {'a': 11, 'b': 12, 'c': 13}) + + # delete constraint from dictionary + h.removeConstr(cM[X[2]], c1, cX, c2, c3, cM, cD) + self.assertEqual(h.numConstrs, 13) + self.assertEqual([int(c1)] + list(map(int, cX)) + [int(c2), int(c3)], [-1,0,1,-1,2,3,5,6]) # unchanged + self.assertEqual({int(k): int(c) for k,c in cM.items()}, {3: 6, 4: 7, 5: -1, 6: 8, 7: 9}) + self.assertEqual({k: int(c) for k,c in cD.items()}, {'a': 10, 'b': 11, 'c': 12}) + + # delete constraint from dictionary + h.removeConstr(cD['b'], c1, cX, c2, c3, cM, cD) + self.assertEqual(h.numConstrs, 12) + self.assertEqual([int(c1)] + list(map(int, cX)) + [int(c2), int(c3)], [-1,0,1,-1,2,3,5,6]) # unchanged + self.assertEqual({int(k): int(c) for k,c in cM.items()}, {3: 6, 4: 7, 5: -1, 6: 8, 7: 9}) # unchanged + self.assertEqual({k: int(c) for k,c in cD.items()}, {'a': 10, 'b': -1, 'c': 11}) + + # delete non-existent constraint + self.assertRaises(Exception, lambda: h.removeConstr(cD['b'], c1, cX, c2, c3, cM, cD)) + + + def test_qsum(self): + """Test summation.""" + h = highspy.Highs() + X = h.addVariables(10) + + # qsum + expr = qsum(X) + self.assertEqualExpr(expr, X, [1]*10) + + expr = qsum((2*x for x in X)) + self.assertEqualExpr(expr, X, [2]*10) + + expr = qsum((qsum(X) for k in range(11))).simplify() + self.assertEqualExpr(expr, X, [11]*10) + + # sum + expr = sum(X) + self.assertEqualExpr(expr, X, [1]*10, 0) + + expr = sum((2*x for x in X)) + self.assertEqualExpr(expr, X, [2]*10, 0) + + # init sum with empty expression + expr = sum((2*x for x in X), highs_linear_expression()) + self.assertEqualExpr(expr, X, [2]*10) + + # manual sum + expr = h.expr() + for x in X: + expr += x + self.assertEqualExpr(expr, X, [1]*10) + + def test_user_interrupts(self): + N = 8 + h = highspy.Highs() + h.silent() + + x = h.addBinaries(N, N) + y = np.fliplr(x) + + h.addConstrs(h.qsum(x[i,:]) == 1 for i in range(N)) # each row has exactly one queen + h.addConstrs(h.qsum(x[:,j]) == 1 for j in range(N)) # each col has exactly one queen + + h.addConstrs(h.qsum(x.diagonal(k)) <= 1 for k in range(-N + 1, N)) # each diagonal has at most one queen + h.addConstrs(h.qsum(y.diagonal(k)) <= 1 for k in range(-N + 1, N)) # each 'reverse' diagonal has at most one queen + + h.HandleUserInterrupt = True + t = h.startSolve() + self.assertRaises(Exception, lambda: h.startSolve()) + h.cancelSolve() + h.wait() + + h = self.get_basic_model() + h.HandleKeyboardInterrupt = True + self.assertEqual(h.HandleKeyboardInterrupt, True) + self.assertEqual(h.HandleUserInterrupt, True) + + h.solve() + h.minimize() + h.maximize() + h.minimize() + + h.joinSolve(h.startSolve()) + h.joinSolve(h.startSolve(), 0) + + # replace wait function with Ctrl+C signal + highspy.highs.Highs.wait = lambda self, t: signal.raise_signal(signal.SIGINT) + h.HandleKeyboardInterrupt = False + + self.assertEqual(h.HandleKeyboardInterrupt, False) + self.assertEqual(h.HandleUserInterrupt, False) + + h.joinSolve(h.startSolve(), 0) + h.startSolve() + h.joinSolve(None, 0) + + with self.assertRaises(SystemExit): + h.startSolve() + h.joinSolve(None, 5) + unittest.main(exit=False) + + def test_callbacks(self): + N = 8 + h = highspy.Highs() + h.silent(False) + + x = h.addBinaries(N, N) + y = np.fliplr(x) + + h.addConstrs(h.qsum(x[i,:]) == 1 for i in range(N)) # each row has exactly one queen + h.addConstrs(h.qsum(x[:,j]) == 1 for j in range(N)) # each col has exactly one queen + + h.addConstrs(h.qsum(x.diagonal(k)) <= 1 for k in range(-N + 1, N)) # each diagonal has at most one queen + h.addConstrs(h.qsum(y.diagonal(k)) <= 1 for k in range(-N + 1, N)) # each 'reverse' diagonal has at most one queen + + do_nothing = lambda e: None + + check_callback = [] + chk_callback = lambda e: check_callback.append(e) + + # check callback is added and called + h.cbLogging += chk_callback + self.assertEqual(len(h.cbLogging.callbacks), 1) + h.solve() + self.assertNotEqual(len(check_callback), 0) + check_callback.clear() + + # check callback is removed and not called + h.cbLogging -= chk_callback + self.assertEqual(len(h.cbLogging.callbacks), 0) + h.solve() + self.assertEqual(len(check_callback), 0) + check_callback.clear() + + h.disableCallbacks() + self.assertRaises(Exception, lambda: h.cbLogging.subscribe(do_nothing)) + h.enableCallbacks() + + h.cbLogging += chk_callback + h.cbSimplexInterrupt += do_nothing + h.cbIpmInterrupt += do_nothing + h.cbMipSolution += do_nothing + h.cbMipImprovingSolution += do_nothing + h.cbMipLogging += do_nothing + h.cbMipInterrupt += do_nothing + h.cbMipGetCutPool += do_nothing + h.cbMipDefineLazyConstraints += do_nothing + + self.assertEqual(len(h.cbLogging.callbacks), 1) + + # check callback is disabled and not called + h.disableCallbacks() + self.assertEqual(len(h.cbLogging.callbacks), 1) + h.solve() + self.assertEqual(len(check_callback), 0) + check_callback.clear() + + # check callback is enabled and called + h.enableCallbacks() + self.assertEqual(len(h.cbLogging.callbacks), 1) + h.solve() + self.assertNotEqual(len(check_callback), 0) + check_callback.clear() + + h.cbLogging -= chk_callback + h.cbSimplexInterrupt -= do_nothing + h.cbIpmInterrupt -= do_nothing + h.cbMipSolution -= do_nothing + h.cbMipImprovingSolution -= do_nothing + h.cbMipLogging -= do_nothing + h.cbMipInterrupt -= do_nothing + h.cbMipGetCutPool -= do_nothing + h.cbMipDefineLazyConstraints -= do_nothing + + self.assertEqual(len(h.cbLogging.callbacks), 0) + h.cbLogging.subscribe(do_nothing) + self.assertEqual(len(h.cbLogging.callbacks), 1) + h.cbLogging.unsubscribe(do_nothing) + self.assertEqual(len(h.cbLogging.callbacks), 0) + h.cbLogging.unsubscribe(do_nothing) # does not throw error if not exists + self.assertEqual(len(h.cbLogging.callbacks), 0) + + h.cbLogging.subscribe(do_nothing, 'test') + self.assertEqual(len(h.cbLogging.callbacks), 1) + h.cbLogging.unsubscribe_by_data('test') + self.assertEqual(len(h.cbLogging.callbacks), 0) + + h.cbLogging += do_nothing + h.cbLogging += do_nothing + h.cbLogging += do_nothing + self.assertEqual(len(h.cbLogging.callbacks), 3) + h.cbLogging.clear() + self.assertEqual(len(h.cbLogging.callbacks), 0) + + +class TestHighsLinearExpressionPy(unittest.TestCase): + def setUp(self): + self.h = highspy.Highs() + self.h.silent() + + self.x = self.h.addVariables(10) + + def assertEqualExpr(self, expr, vars, vals, constant=None, bounds=None): + self.assertEqual(list(map(int, expr.vars)), list(map(int, vars)), 'variable index') + self.assertEqual(expr.vals, vals, 'variable values') + self.assertEqual(expr.constant, constant, 'constant') + self.assertEqual(expr.bounds, bounds, 'bounds') + + def test_init_empty(self): + # Test initialization with no arguments + expr = highspy.highs.highs_linear_expression() + self.assertEqualExpr(expr, [], []) + + expr = self.h.expr() + self.assertEqualExpr(expr, [], []) + self.assertRaises(Exception, lambda: self.h.expr([])) + self.assertRaises(Exception, lambda: self.h.expr(self.h)) + + def test_init_var(self): + # Test initialization with a highs_var + expr = highspy.highs.highs_linear_expression(self.x[0]) + self.assertEqualExpr(expr, [self.x[0]], [1.0]) + + expr = 1.0 * self.x[0] + self.assertEqualExpr(expr, [self.x[0]], [1.0]) + + expr = self.h.expr(self.x[0]) + self.assertEqualExpr(expr, [self.x[0]], [1.0]) + + + def test_init_const(self): + # Test initialization with a constant + expr = highspy.highs.highs_linear_expression(5) + self.assertEqualExpr(expr, [], [], 5) + + expr = self.h.expr(5) + self.assertEqualExpr(expr, [], [], 5) + + + def test_mutable(self): + x,y,z = self.x[0:3] + + expr = x + 2*y + expr2 = expr # reference to expr + expr3 = expr.copy() # copy of expr + self.assertEqualExpr(expr, [x, y], [1, 2]) + self.assertEqualExpr(expr2, [x, y], [1, 2]) + self.assertEqualExpr(expr3, [x, y], [1, 2]) + + expr += z + self.assertEqualExpr(expr, [x, y, z], [1, 2, 1]) + self.assertEqualExpr(expr2, [x, y, z], [1, 2, 1]) + self.assertEqualExpr(expr3, [x, y], [1, 2]) + + expr *= 2 + self.assertEqualExpr(expr, [x, y, z], [2, 4, 2]) + self.assertEqualExpr(expr2, [x, y, z], [2, 4, 2]) + self.assertEqualExpr(expr3, [x, y], [1, 2]) + + # test simplify + expr += x + y + z + expr += x + y + z + expr += x + y + z + self.assertEqualExpr(expr, [x, y, z] + [x, y, z] * 3, [2, 4, 2] + [1, 1, 1]*3) + + expr4 = expr.simplify() + self.assertEqualExpr(expr, [x, y, z] + [x, y, z] * 3, [2, 4, 2] + [1, 1, 1]*3) + self.assertEqualExpr(expr4, [x, y, z], [5, 7, 5]) + + # test edge cases + e1 = x + z <= 3 + e2 = 2 * y + + # addition + self.assertRaises(Exception, lambda: e1 + (e2 + 3)) # cannot add if one has bounds and the other has constant + self.assertRaises(Exception, lambda: e1 + 5) # cannot add constant to expr with bounds + self.assertRaises(Exception, lambda: e1 + []) # unknown type + + expr = e1 + (1 <= (e2 + 4) <= 2) + self.assertEqualExpr(expr, [x, z, y], [1, 1, 2], None, [-self.h.inf, 1]) + + expr = e2.copy() + expr += e1 + self.assertEqualExpr(expr, [y, x, z], [2, 1, 1], None, [-self.h.inf, 3]) + + # subtract + self.assertRaises(Exception, lambda: e1 - (e2 + 3)) # cannot add if one has bounds and the other has constant + self.assertRaises(Exception, lambda: e1 - 5) # cannot add constant to expr with bounds + self.assertRaises(Exception, lambda: e1 - []) # unknown type + + expr = e1 - (1 <= (e2 + 4) <= 2) # (-inf <= x + z <= 3) + (2 <= -2y <= 3) + self.assertEqualExpr(expr, [x, z, y], [1, 1, -2], None, [-self.h.inf, 6]) + + expr = e2.copy() + expr -= e1 + self.assertEqualExpr(expr, [y, x, z], [2, -1, -1], None, [-3, self.h.inf]) + + def test_immutable(self): + x,y,z = self.x[0:3] + + expr = (1*x - 2*y).simplify() + self.assertEqualExpr(expr, [x, y], [1, -2]) + + expr2 = expr + z + self.assertEqualExpr(expr, [x, y], [1, -2]) + self.assertEqualExpr(expr2, [x, y, z], [1, -2, 1]) + + expr2 = expr + 5 + self.assertEqualExpr(expr, [x, y], [1, -2]) + self.assertEqualExpr(expr2, [x, y], [1, -2], 5) + + expr2 = expr <= 3 + self.assertEqualExpr(expr, [x, y], [1, -2]) + self.assertEqualExpr(expr2, [x, y], [1, -2], None, [-self.h.inf, 3]) + + expr2 = 0 <= expr <= 3 + self.assertEqualExpr(expr, [x, y], [1, -2]) + self.assertEqualExpr(expr2, [x, y], [1, -2], None, [0, 3]) + + expr2 = 0 <= expr <= 3 + expr2 += z + self.assertEqualExpr(expr, [x, y], [1, -2]) + self.assertEqualExpr(expr2, [x, y, z], [1, -2, 1], None, [0, 3]) + + def test_negation(self): + # Test negation of a highs_var + expr = -self.x[0] + self.assertEqualExpr(expr, [self.x[0]], [-1]) + + # Test negation of a highs_linear_expression + x,y = self.x[0:2] + expr = x - 2*y + self.assertEqualExpr(expr, [x, y], [1, -2]) + negr = -expr + self.assertEqualExpr(negr, [x, y], [-1, 2]) + + # Test negation of a highs_linear_expression + expr = -self.h.qsum(self.x) + self.assertEqualExpr(expr, list(map(int, self.x)), [-1] * len(self.x)) + + def test_equality(self): + x,y = self.x[0:2] + + expr = x == y + self.assertEqualExpr(expr, [x, y], [1, -1], None, [0, 0]) + + expr = x + y == [1, 2] + self.assertEqualExpr(expr, [x, y], [1, 1], None, [1, 2]) + self.assertRaises(Exception, lambda: x == [x,y]) + self.assertRaises(Exception, lambda: x == self.h) + self.assertRaises(Exception, lambda: x != self.h) + self.assertRaises(Exception, lambda: x + y != y) + self.assertRaises(Exception, lambda: x + 5 != self.h) + + + def test_le_inequality(self): + x,y = self.x[0:2] + + # Test inequality of two highs_linear_expressions + expr = x <= y + self.assertEqualExpr(expr, [x, y], [1, -1], None, [-self.h.inf, 0]) + self.assertRaises(Exception, lambda: x <= self.h) + + + def test_ge_inequality(self): + x,y = self.x[0:2] + + # Test inequality of two highs_linear_expressions + expr = x >= y # y - x <= 0 + self.assertEqualExpr(expr, [y, x], [1, -1], None, [-self.h.inf, 0]) + self.assertRaises(Exception, lambda: x >= self.h) + self.assertRaises(Exception, lambda: x + 4 >= self.h) + self.assertRaises(Exception, lambda: x + 4 >= (y <= 2)) + + + def test_chain_inequality(self): + x,y,z = self.x[0:3] + + # test basic chain inequality + expr = 2 <= x + y <= 6 + self.assertEqualExpr(expr, [x, y], [1, 1], None, [2, 6]) + + expr = 2 <= (x + y) <= 6 + self.assertEqualExpr(expr, [x, y], [1, 1], None, [2, 6]) + + expr = 2 <= x <= 6 + self.assertEqualExpr(expr, [x], [1], None, [2, 6]) + + # advanced chain use cases + expr = y <= 6 + x <= y # -6 <= x - y <= -6 + self.assertEqualExpr(expr, [x, y], [1,-1], None, [-6, -6]) + + expr = x <= 6 <= x # x == 6 + self.assertEqualExpr(expr, [x], [1], None, [6, 6]) + + expr = x + y <= 6 <= x + y # 6 <= x + y <= 6 + self.assertEqualExpr(expr, [x, y], [1, 1], None, [6, 6]) + + expr = x + y + 1 <= 6 <= x + y + 2 # 4 <= x + y <= 5 + self.assertEqualExpr(expr, [x, y], [1, 1], None, [4, 5]) + + + # test chain ordering with a constant + t1 = x + y + t2 = x - y + self.assertEqualExpr(t1, [x, y], [1, 1]) + self.assertEqualExpr(t2, [x, y], [1, -1]) + + expr = (t1 + t2).simplify() # 2x + 0y + self.assertEqualExpr(expr, [x, y], [2, 0]) + + t3 = 2 <= expr <= 4 # 2 <= 2x + 0y <= 4 + self.assertEqualExpr(t3, [x, y], [2, 0], None, [2, 4]) + + vx, vl = [x, y, x, y], [1, 1, 1, -1] + + t3 = 2 <= (t1 + t2) <= 4 # 2 <= 2x + 0y <= 4 + self.assertEqualExpr(t3, vx, vl, None, [2, 4]) + + t3 = 2 <= t1 + t2 <= 4 # 2 <= 2x + 0y <= 4 + self.assertEqualExpr(t3, vx, vl, None, [2, 4]) + + t3 = (t1 + t2) <= 4 # -inf <= 2x + 0y <= 4 + self.assertEqualExpr(t3, vx, vl, None, [-self.h.inf, 4]) + + t3 = t1 + t2 <= 4 # -inf <= 2x + 0y <= 4 + self.assertEqualExpr(t3, vx, vl, None, [-self.h.inf, 4]) + + t3 = 2 <= t1 + t2 # 2 <= 2x + 0y <= inf + self.assertEqualExpr(t3, vx, vl, None, [2, self.h.inf]) + + t3 = t1 + t2 + 5 # 2x + 0y + 5 + self.assertEqualExpr(t3, vx, vl, 5) + + t3 = 5 + t1 + t2 # 2x + 0y + 5 + self.assertEqualExpr(t3, vx, vl, 5) + + t3 = 5 <= t1 + t2 + 5 # 0 <= 2x + 0y <= inf + self.assertEqualExpr(t3, vx, vl, None, [0, self.h.inf]) + + t3 = 5 + t1 + t2 <= 4 # -inf <= 2x + 0y <= -1 + self.assertEqualExpr(t3, vx, vl, None, [-self.h.inf, -1]) + + t3 = 2 <= 5 + t1 + t2 # -3 <= 2x + 0y <= inf + self.assertEqualExpr(t3, vx, vl, None, [-3, self.h.inf]) + + t3 = 2 <= 5 + t1 + t2 <= 6 # -3 <= 2x + 0y <= 1 + self.assertEqualExpr(t3, vx, vl, None, [-3, 1]) + + # test chain with variables on both sides + t3 = 5 + x - x <= y <= 10 # 5 <= y <= 10 + self.assertEqualExpr(t3, [y], [1], None, [5, 10]) + + t3 = 5 + 2*x - 2*x <= y <= 10 # 5 <= y <= 10 + self.assertEqualExpr(t3, [y], [1], None, [5, 10]) + + t3 = 5 <= y <= 10 + 2*x - 2*x # 5 <= y <= 10 + self.assertEqualExpr(t3, [y], [1], None, [5, 10]) + + t3 = 5 - x + x <= y <= 10 + y - y # 5 <= y <= 10 + self.assertEqualExpr(t3, [y], [1], None, [5, 10]) + + t3 = 10 >= y >= 5 + x - x # 5 <= y <= 10 + self.assertEqualExpr(t3, [y], [1], None, [5, 10]) + + t3 = 10 >= y >= 5 + 2*x - 2*x # 5 <= y <= 10 + self.assertEqualExpr(t3, [y], [1], None, [5, 10]) + + t3 = 10 + 2*x - 2*x >= y >= 5 # 5 <= y <= 10 + self.assertEqualExpr(t3, [y], [1], None, [5, 10]) + + t3 = 10 + y - y >= y >= 5 - x + x # 5 <= y <= 10 + self.assertEqualExpr(t3, [y], [1], None, [5, 10]) + + + vx, vl, nl = list(self.x), [1]*len(self.x), [-1]*len(self.x) + t3 = qsum(self.x) <= 10 # -inf <= sum(x) <= 10 + self.assertEqualExpr(t3, vx, vl, None, [-self.h.inf, 10]) + + t3 = qsum(self.x) <= y # -inf <= sum(x) - y <= 0 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [-self.h.inf, 0]) + + t3 = y <= qsum(self.x) <= y # sum(x) == 0 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [0, 0]) + + t3 = y >= qsum(self.x) >= y # sum(x) == 0 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [0, 0]) + + t3 = qsum(self.x) == y # sum(x) - y == 0 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [0, 0]) + + t3 = y == qsum(self.x) # sum(x) - y == 0 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [0, 0]) + + t3 = y + 1 <= qsum(self.x) <= y + 6 # 1 <= sum(x) - y <= 6 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [1, 6]) + + t3 = y + 6 >= qsum(self.x) >= y + 1 # 1 <= sum(x) - y <= 6 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [1, 6]) + + t3 = qsum(self.x) >= y >= qsum(self.x) # sum(x) - y == 0 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [0, 0]) + + t3 = qsum(self.x) <= y <= qsum(self.x) # sum(x) - y == 0 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [0, 0]) + + t3 = qsum(self.x) + 5 >= y >= qsum(self.x) + 2 # -5 <= sum(x) - y <= -2 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [-5, -2]) + + t3 = qsum(self.x) + 2 <= y <= qsum(self.x) + 5 # -5 <= sum(x) - y <= -2 + self.assertEqualExpr(t3, vx + [y], vl + [-1], None, [-5, -2]) + + t3 = (qsum(self.x) == 1 + qsum(self.x)).simplify() # [] == 1 + self.assertEqualExpr(t3, vx, [0]*len(vx), None, [1, 1]) + + self.assertRaises(Exception, lambda: x <= y <= 1) + self.assertRaises(Exception, lambda: (qsum(self.x) + 5 >= y) >= qsum(self.x) + 2) + self.assertRaises(Exception, lambda: qsum(self.x) + 2 <= (y <= qsum(self.x) + 5)) + self.assertRaises(Exception, lambda: qsum(self.x) + 2 <= y <= 5) + self.assertRaises(Exception, lambda: 2 <= y <= 5 + qsum(self.x)) + + def test_order_priority(self): + x,y,z = self.x[:3] + + # prefer more variables + self.assertEqualExpr(x <= y + z, [y, z, x], [1, 1, -1], None, [ 0, self.h.inf]) + self.assertEqualExpr(y + z >= x, [y, z, x], [1, 1, -1], None, [ 0, self.h.inf]) + self.assertEqualExpr(x >= y + z, [y, z, x], [1, 1, -1], None, [-self.h.inf, 0]) + self.assertEqualExpr(y + z <= x, [y, z, x], [1, 1, -1], None, [-self.h.inf, 0]) + self.assertEqualExpr(y + z == x, [y, z, x], [1, 1, -1], None, [0, 0]) + self.assertEqualExpr(x == y + z, [y, z, x], [1, 1, -1], None, [0, 0]) + self.assertEqualExpr(x + y == 2*x + 2*y + z, [x, y, z, x, y], [2, 2, 1, -1, -1], None, [0, 0]) + + # prefer constant + self.assertEqualExpr(y <= x + 2, [y, x], [1, -1], None, [-self.h.inf, 2]) + self.assertEqualExpr(x + 2 >= y, [y, x], [1, -1], None, [-self.h.inf, 2]) + self.assertEqualExpr(x + 2 <= y, [y, x], [1, -1], None, [ 2, self.h.inf]) + self.assertEqualExpr(y >= x + 2, [y, x], [1, -1], None, [ 2, self.h.inf]) + self.assertEqualExpr(y + 2 == x, [x, y], [1, -1], None, [2, 2]) + self.assertEqualExpr(x == y + 2, [x, y], [1, -1], None, [2, 2]) + + # prefer 'left' + self.assertEqualExpr(x + 2 <= y + 3, [x,y], [1,-1], None, [-self.h.inf, 1]) + self.assertEqualExpr(y + 3 >= x + 2, [x,y], [1,-1], None, [-self.h.inf, 1]) + self.assertEqualExpr(x + 2 >= y + 3, [y,x], [1,-1], None, [-self.h.inf, -1]) + self.assertEqualExpr(y + 3 <= x + 2, [y,x], [1,-1], None, [-self.h.inf, -1]) + self.assertEqualExpr(x + 2 == y + 3, [x,y], [1,-1], None, [ 1, 1]) + self.assertEqualExpr(y + 3 == x + 2, [y,x], [1,-1], None, [-1,-1]) + + self.assertEqualExpr(6 <= x + y <= 8, [x,y], [1,1], None, [6,8]) + self.assertEqualExpr(6 + x <= y <= 8 + x, [y,x], [1,-1], None, [6,8]) + self.assertEqualExpr(6 + x <= y + 2 <= 8 + x, [y,x], [1,-1], None, [4,6]) + self.assertEqualExpr(x <= 6 <= x, [x], [1], None, [6,6]) + self.assertEqualExpr(x <= y + z <= x + 5, [y,z,x], [1,1,-1], None, [0,5]) + + def test_chain_inequality_hacks(self): + x,y = self.x[0:2] + + # Test hacks around chain inequality + # These don't need to be supported, good to check if logic changes + t = x + y + self.assertEqualExpr(t, [x, y], [1, 1]) + + bool(t <= 10) + expr = 5 <= t + self.assertEqualExpr(expr, [x, y], [1, 1], None, [5, 10]) + expr = 5 <= t + self.assertEqualExpr(expr, [x, y], [1, 1], None, [5, self.h.inf]) + + bool(t <= 10) + expr = t <= 18 + self.assertEqualExpr(expr, [x, y], [1, 1], None, [-self.h.inf, 18]) + + bool(t <= 10) + expr = t >= 5 + self.assertEqualExpr(expr, [x, y], [1, 1], None, [5, 10]) + expr = t >= 5 + self.assertEqualExpr(expr, [x, y], [1, 1], None, [5, self.h.inf]) + + expr = bool(t <= 10) and t >= 5 + self.assertEqualExpr(expr, [x, y], [1, 1], None, [5, 10]) + + # test hack if expression is modified + bool(t <= 10) + t += y + expr = 5 <= t + self.assertEqualExpr(expr, [x, y, y], [1, 1, 1], None, [5, self.h.inf]) + + # test hack if using 'equal' temporary expr + bool(x + y <= 10) + expr = 5 <= x + y + self.assertEqualExpr(expr, [x, y], [1, 1], None, [5, 10]) + expr = 5 <= x + y + self.assertEqualExpr(expr, [x, y], [1, 1], None, [5, self.h.inf]) + + # test hack if using 'not-equal' temporary expr + bool(x + 3*y <= 10) + expr = 5 <= x + y + self.assertEqualExpr(expr, [x, y], [1, 1], None, [5, self.h.inf]) + + def test_bounds_already_set(self): + x,y = self.x[0:2] + self.assertRaises(Exception, lambda: (1 <= 2*x + 3*y) <= 5) + self.assertRaises(Exception, lambda: (x <= 4) <= 5) + self.assertRaises(Exception, lambda: 2 <= (x <= 4) <= 5) + self.assertRaises(Exception, lambda: 2 <= (4 <= x)) + self.assertRaises(Exception, lambda: 2 <= (x <= 4)) + self.assertRaises(Exception, lambda: 4 >= (x >= 2)) + + # Cannot a constant if bounds already exist + self.assertRaises(Exception, lambda: (x + y <= 3) + 5) + self.assertEqualExpr((x + y <= 3) + (self.h.expr() == 5), [x, y], [1, 1], None, [-self.h.inf, 8]) + + # Cannot a expr with constant if bounds already exist + self.assertRaises(Exception, lambda: (x + y <= 3) + (x + 5)) + self.assertEqualExpr((x + y <= 3) + (x == -5), [x, y, x], [1, 1, 1], None, [-self.h.inf, -2]) + + def test_addition(self): + x,y = self.x[0:2] + + # Test addition of two variables + expr = x + y + self.assertEqualExpr(expr, [x, y], [1, 1]) + + # Test addition of two exprs (with bounds) + e1 = 2 <= x - y <= 4 + e2 = 2 <= 2*x - y <= 4 + self.assertEqualExpr(e1, [x, y], [1, -1], None, [2, 4]) + self.assertEqualExpr(e2, [x, y], [2, -1], None, [2, 4]) + + expr = e1 + e2 + self.assertEqualExpr(expr, [x, y, x, y], [1, -1, 2, -1], None, [4, 8]) + + expr = (e1 + e2).simplify() + self.assertEqualExpr(expr, [x, y], [3, -2], None, [4, 8]) + + # Test multiplication/addition of two exprs (with bounds) + expr = (2*e1 + 3*e2).simplify() + self.assertEqualExpr(expr, [x, y], [8, -5], None, [10, 20]) + + + def test_subtraction(self): + x,y = self.x[0:2] + + # Test subtraction of two highs_linear_expressions + expr = x - y + self.assertEqualExpr(expr, [x, y], [1, -1]) + + # Test subtraction of two exprs (with bounds) + e1 = 2 <= x - y <= 4 + e2 = 2 <= 2*x - y <= 4 + self.assertEqualExpr(e1, [x, y], [1, -1], None, [2, 4]) + self.assertEqualExpr(e2, [x, y], [2, -1], None, [2, 4]) + + expr = e1 + (-1.0 * e2) + self.assertEqualExpr(expr, [x, y, x, y], [1, -1, -2, 1], None, [-2, 2]) + + expr = e1 + (-e2) + self.assertEqualExpr(expr, [x, y, x, y], [1, -1, -2, 1], None, [-2, 2]) + + expr = e1 - e2 + self.assertEqualExpr(expr, [x, y, x, y], [1, -1, -2, 1], None, [-2, 2]) + + expr = (e1 - e2).simplify() + self.assertEqualExpr(expr, [x, y], [-1, 0], None, [-2, 2]) + + # Test multiplication/subtraction of two exprs (with bounds) + expr = (2*e1 - e2).simplify() + self.assertEqualExpr(expr, [x, y], [0, -1], None, [0, 6]) + + # test rsub + expr = 5 - (x + y) + self.assertEqualExpr(expr, [x, y], [-1, -1], 5) + + def test_multiply(self): + x,y = self.x[0:2] + + # basic tests + e1 = x * 3 + self.assertEqualExpr(e1, [x], [3]) + e1 = 3 * x + self.assertEqualExpr(e1, [x], [3]) + + e1 = x - y + self.assertEqualExpr(e1, [x, y], [1, -1]) + + e2 = 2 * e1 + self.assertEqualExpr(e2, [x, y], [2, -2]) + + e1 *= 3 + self.assertEqualExpr(e1, [x, y], [3, -3]) + + e2 = -1 * e1 + self.assertEqualExpr(e2, [x, y], [-3, 3]) + + e2 = 0 * e1 + self.assertEqualExpr(e2, [x, y], [0, 0]) + + e2 = highs_linear_expression(-1) * e1 + self.assertEqualExpr(e2, [x, y], [-3, 3]) + + self.assertRaises(Exception, lambda: e1 * x) + self.assertRaises(Exception, lambda: e1 * highs_linear_expression(x)) + self.assertRaises(Exception, lambda: e1 * highs_linear_expression(x - x)) + + # Test with constant + e1 = x - y + 4 + self.assertEqualExpr(e1, [x, y], [1, -1], 4) + + e2 = e1 * 2.5 + self.assertEqualExpr(e2, [x, y], [2.5, -2.5], 10) + + e1 *= 2.5 + self.assertEqualExpr(e1, [x, y], [2.5, -2.5], 10) + + e2 = -1.0 * e1 + self.assertEqualExpr(e2, [x, y], [-2.5, 2.5], -10) + + e1 *= -1.0 + self.assertEqualExpr(e1, [x, y], [-2.5, 2.5], -10) + + e1 *= highs_linear_expression(-1.0) + self.assertEqualExpr(e1, [x, y], [2.5, -2.5], 10) + + # Test with bounds + e1 = x - 2*y == [1, 4] + self.assertEqualExpr(e1, [x, y], [1, -2], None, [1, 4]) + + e2 = e1 * 2.5 + self.assertEqualExpr(e2, [x, y], [2.5, -5], None, [2.5, 10]) + + e1 *= 2.5 + self.assertEqualExpr(e1, [x, y], [2.5, -5], None, [2.5, 10]) + + e2 = -1.0 * e1 + self.assertEqualExpr(e2, [x, y], [-2.5, 5], None, [-10, -2.5]) + + e1 *= -1.0 + self.assertEqualExpr(e1, [x, y], [-2.5, 5], None, [-10, -2.5]) + + e1 *= highs_linear_expression(-1.0) + self.assertEqualExpr(e1, [x, y], [2.5, -5], None, [2.5, 10]) + + e1 = highs_linear_expression(-1.0) + e1 *= x + 4 + self.assertEqualExpr(e1, [x], [-1], -4) + + e1 = highs_linear_expression(-1.0) + e1 *= x - 4 <= 3 + self.assertEqualExpr(e1, [x], [-1], None, [-7, self.h.inf]) + + e1 = highs_linear_expression(1.0) + e1 *= x - 4 <= 3 + self.assertEqualExpr(e1, [x], [1], None, [-self.h.inf, 7]) + + + def test_simplify(self): + x, y, z = self.x[0:3] + + # basics + expr = x + y + z + self.assertEqualExpr(expr, [x, y, z], [1, 1, 1]) + expr = expr.simplify() + self.assertEqualExpr(expr, [x, y, z], [1, 1, 1]) + + expr = z + x + y # simplify reorders variables + self.assertEqualExpr(expr, [z, x, y], [1, 1, 1]) + expr = expr.simplify() + self.assertEqualExpr(expr, [x, y, z], [1, 1, 1]) + + expr = x + x + x + x + x + self.assertEqualExpr(expr, [x]*5, [1]*5) + expr = expr.simplify() + self.assertEqualExpr(expr, [x], [5]) + + expr = x - x + x - x + x + self.assertEqualExpr(expr, [x]*5, [1,-1,1,-1,1]) + expr = expr.simplify() + self.assertEqualExpr(expr, [x], [1]) + + expr = -x + expr += x + expr *= -5 + self.assertEqualExpr(expr, [x,x], [5,-5]) + expr = expr.simplify() + self.assertEqualExpr(expr, [x], [0]) + + # with constant + expr = x + y + 1 + y + self.assertEqualExpr(expr, [x,y,y], [1,1,1], 1) + expr = expr.simplify() + self.assertEqualExpr(expr, [x,y], [1,2], 1) + + # with bounds + expr = 0 <= x + y + 1 + y <= 5 + self.assertEqualExpr(expr, [x,y,y], [1,1,1], None, [-1, 4]) + expr = expr.simplify() + self.assertEqualExpr(expr, [x,y], [1,2], None, [-1, 4]) + + def test_evaluate(self): + h = self.h + x,y = h.addVariables(2, lb = -h.inf) + + h.addConstrs(-x + y >= 2, x + y >= 0) + h.minimize(y) + + self.assertAlmostEqual(h.val(x), -1) + self.assertAlmostEqual(h.val(y), 1) + self.assertAlmostEqual(h.val(x + y), 0) + self.assertAlmostEqual(h.val(y - x), 2) + self.assertEqual(h.val(y - x >= 2), True) + self.assertEqual(h.val(y - x <= 1), False) + + def test_repr_str(self): + x,y = self.x[0:2] + self.assertEqual(repr(x), 'highs_var(0)') + self.assertEqual(str(x), 'highs_var(0)') + + c = self.h.addConstr(x + y <= 5) + self.assertEqual(repr(c), 'highs_cons(0)') + self.assertEqual(str(c), 'highs_cons(0)') + + expr = c.expr() + self.assertEqual(repr(expr), '-inf <= 1.0_v0 1.0_v1 <= 5.0') + self.assertEqual(str(expr), '-inf <= 1.0_v0 1.0_v1 <= 5.0') + + + expr = x + y + self.assertEqual(repr(expr), '1.0_v0 1.0_v1') + self.assertEqual(str(expr), '1.0_v0 1.0_v1') + + expr = x + y + x + x + y + self.assertEqual(repr(expr), '1.0_v0 1.0_v1 1.0_v0 1.0_v0 1.0_v1') + self.assertEqual(str(expr), '3.0_v0 2.0_v1') + + expr = y + x + 5 + self.assertEqual(repr(expr), '1.0_v1 1.0_v0 5.0') + self.assertEqual(str(expr), '1.0_v0 1.0_v1 5.0') + + expr = y + x == 5 + self.assertEqual(repr(expr), '1.0_v1 1.0_v0 == 5.0') + self.assertEqual(str(expr), '1.0_v0 1.0_v1 == 5.0') + + expr = y + x <= 5 + self.assertEqual(repr(expr), '-inf <= 1.0_v1 1.0_v0 <= 5.0') + self.assertEqual(str(expr), '-inf <= 1.0_v0 1.0_v1 <= 5.0')