diff --git a/ortools/math_opt/core/python/BUILD.bazel b/ortools/math_opt/core/python/BUILD.bazel new file mode 100644 index 0000000000..be4704d42d --- /dev/null +++ b/ortools/math_opt/core/python/BUILD.bazel @@ -0,0 +1,33 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@pybind11_bazel//:build_defs.bzl", "pybind_extension") + +package(default_visibility = ["//ortools/math_opt:__subpackages__"]) + +pybind_extension( + name = "solver", + srcs = ["solver.cc"], + deps = [ + "//ortools/math_opt:result_cc_proto", + "//ortools/math_opt/core:solve_interrupter", + "//ortools/math_opt/core:solver", + "//ortools/math_opt/core:solver_debug", + "//ortools/math_opt/solvers:cp_sat_solver", + "//ortools/math_opt/solvers:glop_solver", + "//ortools/math_opt/solvers:glpk_solver", + "//ortools/math_opt/solvers:gscip_solver", + "@pybind11_abseil//pybind11_abseil:status_casters", + "@pybind11_protobuf//pybind11_protobuf:native_proto_caster", + ], +) diff --git a/ortools/math_opt/core/python/solver.cc b/ortools/math_opt/core/python/solver.cc new file mode 100644 index 0000000000..f04b4ff19f --- /dev/null +++ b/ortools/math_opt/core/python/solver.cc @@ -0,0 +1,152 @@ +// Copyright 2010-2022 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/math_opt/core/solver.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "ortools/math_opt/core/solve_interrupter.h" +#include "ortools/math_opt/core/solver_debug.h" +#include "ortools/math_opt/result.pb.h" +#include "pybind11/cast.h" +#include "pybind11_abseil/status_casters.h" // IWYU pragma: keep +#include "pybind11_protobuf/native_proto_caster.h" + +namespace operations_research::math_opt { + +namespace py = ::pybind11; + +using PybindSolverCallback = + std::function; + +using PybindSolverMessageCallback = + std::function)>; + +// Wrapper for Solver::NonIncrementalSolve with flat arguments. +absl::StatusOr PybindSolve( + const ModelProto& model, const SolverTypeProto solver_type, + SolverInitializerProto solver_initializer, SolveParametersProto parameters, + ModelSolveParametersProto model_parameters, + PybindSolverMessageCallback message_callback, + CallbackRegistrationProto callback_registration, + PybindSolverCallback user_cb, SolveInterrupter* const interrupter) { + return Solver::NonIncrementalSolve( + model, solver_type, {.streamable = std::move(solver_initializer)}, + {.parameters = std::move(parameters), + .model_parameters = std::move(model_parameters), + .message_callback = std::move(message_callback), + .callback_registration = std::move(callback_registration), + .user_cb = std::move(user_cb), + .interrupter = interrupter}); +} + +// Wrapper for Solver::NonIncrementalComputeInfeasibleSubsystem +absl::StatusOr +PybindComputeInfeasibleSubsystem(const ModelProto& model, + const SolverTypeProto solver_type, + SolverInitializerProto solver_initializer, + SolveParametersProto parameters, + PybindSolverMessageCallback message_callback, + SolveInterrupter* const interrupter) { + return Solver::NonIncrementalComputeInfeasibleSubsystem( + model, solver_type, {.streamable = std::move(solver_initializer)}, + {.parameters = std::move(parameters), + .message_callback = std::move(message_callback), + .interrupter = interrupter}); +} + +// Wrapper for the Solver class with flat arguments. +class PybindSolver { + public: + static absl::StatusOr> New( + const SolverTypeProto solver_type, const ModelProto& model, + SolverInitializerProto solver_initializer) { + ASSIGN_OR_RETURN( + std::unique_ptr solver, + Solver::New(solver_type, model, + {.streamable = std::move(solver_initializer)})); + return absl::WrapUnique(new PybindSolver(std::move(solver))); + } + + static int64_t DebugNumSolver() { return internal::debug_num_solver.load(); } + + PybindSolver(const PybindSolver&) = delete; + PybindSolver& operator=(const PybindSolver&) = delete; + + absl::StatusOr Solve( + SolveParametersProto parameters, + ModelSolveParametersProto model_parameters, + PybindSolverMessageCallback message_callback, + CallbackRegistrationProto callback_registration, + PybindSolverCallback user_cb, SolveInterrupter* const interrupter) { + return solver_->Solve( + {.parameters = std::move(parameters), + .model_parameters = std::move(model_parameters), + .message_callback = std::move(message_callback), + .callback_registration = std::move(callback_registration), + .user_cb = std::move(user_cb), + .interrupter = interrupter}); + } + + absl::StatusOr Update(const ModelUpdateProto& model_update) { + return solver_->Update(model_update); + } + + private: + explicit PybindSolver(std::unique_ptr solver) + : solver_(std::move(solver)) {} + + const std::unique_ptr solver_; +}; + +PYBIND11_MODULE(solver, m) { + pybind11_protobuf::ImportNativeProtoCasters(); + pybind11::google::ImportStatusModule(); + + // The Global Interpreter Lock (GIL) is released with gil_scoped_release + // during the solve to allow Python threads to run callbacks in parallel. + m.def("solve", &PybindSolve, py::arg("model"), py::arg("solver_type"), + py::arg("solver_initializer"), py::arg("parameters"), + py::arg("model_parameters"), py::arg("message_cb"), + py::arg("callback_registration"), py::arg("user_cb"), + py::arg("interrupt"), py::call_guard()); + m.def("compute_infeasible_subsystem", &PybindComputeInfeasibleSubsystem, + py::arg("model"), py::arg("solver_type"), py::arg("solver_initializer"), + py::arg("parameters"), py::arg("message_cb"), py::arg("interrupt"), + py::call_guard()); + m.def("new", &PybindSolver::New, py::arg("solver_type"), py::arg("model"), + py::arg("solver_initializer"), + py::call_guard()); + m.def("debug_num_solver", &PybindSolver::DebugNumSolver); + + py::class_(m, "Solver") + .def("solve", &PybindSolver::Solve, + py::call_guard()) + .def("update", &PybindSolver::Update, + py::call_guard()); + + py::class_(m, "SolveInterrupter") + .def(py::init()) + .def("interrupt", &SolveInterrupter::Interrupt) + .def("is_interrupted", &SolveInterrupter::IsInterrupted); +} + +} // namespace operations_research::math_opt diff --git a/ortools/math_opt/core/python/solver_gurobi_test.py b/ortools/math_opt/core/python/solver_gurobi_test.py new file mode 100644 index 0000000000..83622438c5 --- /dev/null +++ b/ortools/math_opt/core/python/solver_gurobi_test.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import math + +import unittest +from google3.third_party.pybind11_abseil.status import StatusNotOk +from ortools.math_opt import infeasible_subsystem_pb2 +from ortools.math_opt import model_pb2 +from ortools.math_opt import parameters_pb2 +from ortools.math_opt import result_pb2 +from ortools.math_opt.core.python import solver +from ortools.math_opt.python.testing import compare_proto + + +# The model is: +# x + z <= 4 (c) +# 3 <= x + z (d) +# x, y, z in [0, 1] +# The IIS is x upper bound, z upper bound, (d) lower bound +def _simple_infeasible_model() -> model_pb2.ModelProto: + model = model_pb2.ModelProto() + model.variables.ids[:] = [0, 1, 2] + model.variables.lower_bounds[:] = [0.0, 0.0, 0.0] + model.variables.upper_bounds[:] = [1.0, 1.0, 1.0] + model.variables.integers[:] = [False, False, False] + model.linear_constraints.ids[:] = [0, 1] + model.linear_constraints.lower_bounds[:] = [-math.inf, 3.0] + model.linear_constraints.upper_bounds[:] = [4.0, math.inf] + model.linear_constraint_matrix.row_ids[:] = [0, 0, 1, 1] + model.linear_constraint_matrix.column_ids[:] = [0, 2, 0, 2] + model.linear_constraint_matrix.coefficients[:] = [1.0, 1.0, 1.0, 1.0] + return model + + +# The model is +# 2*x + 2*y + 2*z >= 3.0 +# x + y <= 1 +# y + z <= 1 +# x + z <= 1 +# x, y, z in {0, 1} +def _nontrivial_infeasible_model() -> model_pb2.ModelProto: + model = model_pb2.ModelProto() + model.variables.ids[:] = [0, 1, 2] + model.variables.lower_bounds[:] = [0.0, 0.0, 0.0] + model.variables.upper_bounds[:] = [1.0, 1.0, 1.0] + model.variables.integers[:] = [True, True, True] + model.linear_constraints.ids[:] = [0, 1, 2, 3] + model.linear_constraints.lower_bounds[:] = [ + 3.0, + -math.inf, + -math.inf, + -math.inf, + ] + model.linear_constraints.upper_bounds[:] = [math.inf, 1.0, 1.0, 1.0] + model.linear_constraint_matrix.row_ids[:] = [0, 0, 0, 1, 1, 2, 2, 3, 3] + model.linear_constraint_matrix.column_ids[:] = [0, 1, 2, 0, 1, 1, 2, 0, 2] + model.linear_constraint_matrix.coefficients[:] = [ + 2.0, + 2.0, + 2.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ] + return model + + +def _expected_iis_success() -> ( + infeasible_subsystem_pb2.ComputeInfeasibleSubsystemResultProto +): + expected = infeasible_subsystem_pb2.ComputeInfeasibleSubsystemResultProto( + is_minimal=True, feasibility=result_pb2.FEASIBILITY_STATUS_INFEASIBLE + ) + expected.infeasible_subsystem.variable_bounds[0].upper = True + expected.infeasible_subsystem.variable_bounds[2].upper = True + expected.infeasible_subsystem.linear_constraints[1].lower = True + return expected + + +class PybindComputeInfeasibleSubsystemTest( + compare_proto.MathOptProtoAssertions, unittest.TestCase +): + def test_compute_infeasible_subsystem_infeasible(self) -> None: + iis_result = solver.compute_infeasible_subsystem( + _simple_infeasible_model(), + parameters_pb2.SOLVER_TYPE_GUROBI, + parameters_pb2.SolverInitializerProto(), + parameters_pb2.SolveParametersProto(), + None, + None, + ) + self.assert_protos_equiv(iis_result, _expected_iis_success()) + + def test_compute_infeasible_subsystem_infeasible_uninterrupted(self) -> None: + interrupter = solver.SolveInterrupter() + iis_result = solver.compute_infeasible_subsystem( + _simple_infeasible_model(), + parameters_pb2.SOLVER_TYPE_GUROBI, + parameters_pb2.SolverInitializerProto(), + parameters_pb2.SolveParametersProto(), + None, + interrupter, + ) + self.assert_protos_equiv(iis_result, _expected_iis_success()) + + def test_compute_infeasible_subsystem_interrupted(self) -> None: + interrupter = solver.SolveInterrupter() + interrupter.interrupt() + iis_result = solver.compute_infeasible_subsystem( + _nontrivial_infeasible_model(), + parameters_pb2.SOLVER_TYPE_GUROBI, + parameters_pb2.SolverInitializerProto(), + parameters_pb2.SolveParametersProto(), + None, + interrupter, + ) + expected = infeasible_subsystem_pb2.ComputeInfeasibleSubsystemResultProto( + feasibility=result_pb2.FEASIBILITY_STATUS_UNDETERMINED + ) + self.assert_protos_equiv(iis_result, expected) + + def test_compute_infeasible_subsystem_time_limit(self) -> None: + params = parameters_pb2.SolveParametersProto() + params.time_limit.FromTimedelta(datetime.timedelta(seconds=0.0)) + iis_result = solver.compute_infeasible_subsystem( + _nontrivial_infeasible_model(), + parameters_pb2.SOLVER_TYPE_GUROBI, + parameters_pb2.SolverInitializerProto(), + params, + None, + None, + ) + expected = infeasible_subsystem_pb2.ComputeInfeasibleSubsystemResultProto( + feasibility=result_pb2.FEASIBILITY_STATUS_UNDETERMINED + ) + self.assert_protos_equiv(iis_result, expected) + + def test_compute_infeasible_subsystem_infeasible_message_cb(self) -> None: + messages = [] + iis_result = solver.compute_infeasible_subsystem( + _simple_infeasible_model(), + parameters_pb2.SOLVER_TYPE_GUROBI, + parameters_pb2.SolverInitializerProto(), + parameters_pb2.SolveParametersProto(), + messages.extend, + None, + ) + self.assert_protos_equiv(iis_result, _expected_iis_success()) + self.assertIn("IIS computed", "\n".join(messages)) + + def test_compute_infeasible_subsystem_error_wrong_solver(self) -> None: + with self.assertRaisesRegex(StatusNotOk, "SOLVER_TYPE_GLPK is not registered"): + solver.compute_infeasible_subsystem( + _simple_infeasible_model(), + parameters_pb2.SOLVER_TYPE_GLPK, + parameters_pb2.SolverInitializerProto(), + parameters_pb2.SolveParametersProto(), + None, + None, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/core/python/solver_test.py b/ortools/math_opt/core/python/solver_test.py new file mode 100644 index 0000000000..f77d56564e --- /dev/null +++ b/ortools/math_opt/core/python/solver_test.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import threading +from typing import Callable, Optional, Sequence +import unittest +from google3.testing.pybase import parameterized +from google3.third_party.pybind11_abseil.status import StatusNotOk +from ortools.math_opt import callback_pb2 +from ortools.math_opt import model_parameters_pb2 +from ortools.math_opt import model_pb2 +from ortools.math_opt import model_update_pb2 +from ortools.math_opt import parameters_pb2 +from ortools.math_opt import result_pb2 +from ortools.math_opt.core.python import solver + + +def _build_simple_model() -> model_pb2.ModelProto: + model = model_pb2.ModelProto() + model.variables.ids.append(0) + model.variables.lower_bounds.append(1.0) + model.variables.upper_bounds.append(2.0) + model.variables.integers.append(False) + model.variables.names.append("x") + model.objective.maximize = True + model.objective.linear_coefficients.ids.append(0) + model.objective.linear_coefficients.values.append(1.0) + return model + + +def _solve_model( + model: model_pb2.ModelProto, + *, + use_solver_class: bool, + solver_type: parameters_pb2.SolverTypeProto = parameters_pb2.SOLVER_TYPE_GLOP, + solver_initializer: parameters_pb2.SolverInitializerProto = parameters_pb2.SolverInitializerProto(), + parameters: parameters_pb2.SolveParametersProto = parameters_pb2.SolveParametersProto(), + model_parameters: model_parameters_pb2.ModelSolveParametersProto = model_parameters_pb2.ModelSolveParametersProto(), + message_callback: Optional[Callable[[Sequence[str]], None]] = None, + callback_registration: callback_pb2.CallbackRegistrationProto = callback_pb2.CallbackRegistrationProto(), + user_cb: Optional[ + Callable[[callback_pb2.CallbackDataProto], callback_pb2.CallbackResultProto] + ] = None, + interrupter: Optional[solver.SolveInterrupter] = None, +) -> result_pb2.SolveResultProto: + """Convenience function for both types of solve with parameter defaults.""" + if use_solver_class: + pybind_solver = solver.new( + solver_type, + model, + solver_initializer, + ) + return pybind_solver.solve( + parameters, + model_parameters, + message_callback, + callback_registration, + user_cb, + interrupter, + ) + else: + return solver.solve( + model, + solver_type, + solver_initializer, + parameters, + model_parameters, + message_callback, + callback_registration, + user_cb, + interrupter, + ) + + +class PybindSolverTest(parameterized.TestCase): + def tearDown(self): + super().tearDown() + self.assertEqual(solver.debug_num_solver(), 0) + + @parameterized.named_parameters( + dict(testcase_name="without_solver", use_solver_class=False), + dict(testcase_name="with_solver", use_solver_class=True), + ) + def test_valid_solve(self, use_solver_class: bool) -> None: + model = _build_simple_model() + result = _solve_model(model, use_solver_class=use_solver_class) + self.assertEqual( + result.termination.reason, result_pb2.TERMINATION_REASON_OPTIMAL + ) + self.assertTrue(result.solutions) + self.assertAlmostEqual(result.solutions[0].primal_solution.objective_value, 2.0) + + @parameterized.named_parameters( + dict(testcase_name="without_solver", use_solver_class=False), + dict(testcase_name="with_solver", use_solver_class=True), + ) + def test_invalid_input_throws_error(self, use_solver_class: bool) -> None: + model = _build_simple_model() + # Add invalid variable id to cause MathOpt model validation error. + model.objective.linear_coefficients.ids.append(7) + model.objective.linear_coefficients.values.append(2.0) + with self.assertRaisesRegex(StatusNotOk, "id 7 not found"): + _solve_model(model, use_solver_class=use_solver_class) + + @parameterized.named_parameters( + dict(testcase_name="without_solver", use_solver_class=False), + dict(testcase_name="with_solver", use_solver_class=True), + ) + def test_solve_interrupter_interrupts_solve(self, use_solver_class: bool) -> None: + model = _build_simple_model() + interrupter = solver.SolveInterrupter() + interrupter.interrupt() + result = _solve_model( + model, use_solver_class=use_solver_class, interrupter=interrupter + ) + self.assertEqual( + result.termination.reason, + result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND, + ) + self.assertEqual(result.termination.limit, result_pb2.LIMIT_INTERRUPTED) + + @parameterized.named_parameters( + dict(testcase_name="without_solver", use_solver_class=False), + dict(testcase_name="with_solver", use_solver_class=True), + ) + def test_message_callback_is_invoked(self, use_solver_class: bool) -> None: + model = _build_simple_model() + messages = [] + # Message callback extends `messages` with solver output. + _solve_model( + model, + use_solver_class=use_solver_class, + parameters=parameters_pb2.SolveParametersProto( + enable_output=True, threads=1 + ), + message_callback=messages.extend, + ) + self.assertIn("status:", "\n".join(messages)) + + @parameterized.named_parameters( + dict(testcase_name="without_solver", use_solver_class=False), + dict(testcase_name="with_solver", use_solver_class=True), + ) + def test_user_callback_is_invoked(self, use_solver_class: bool) -> None: + model = _build_simple_model() + solution_values = [] + mutex = threading.Lock() + + # Callback stores solution values found during solve in `solution_values`. + def collect_solution_values_user_callback( + cb_data: callback_pb2.CallbackDataProto, + ) -> callback_pb2.CallbackResultProto: + with mutex: + assert cb_data.event == callback_pb2.CALLBACK_EVENT_MIP_SOLUTION + solution_values.extend(cb_data.primal_solution_vector.values) + return callback_pb2.CallbackResultProto() + + # This implicitly tests that the GIL is released, since the solve below can + # deadlock otherwise. + result = _solve_model( + model, + use_solver_class=use_solver_class, + solver_type=parameters_pb2.SOLVER_TYPE_CP_SAT, + callback_registration=callback_pb2.CallbackRegistrationProto( + request_registration=[callback_pb2.CALLBACK_EVENT_MIP_SOLUTION] + ), + user_cb=collect_solution_values_user_callback, + ) + self.assertEqual( + result.termination.reason, result_pb2.TERMINATION_REASON_OPTIMAL + ) + # `solution_values` should at least contain the optimal value 2.0. + self.assertContainsSubset(solution_values, [2.0]) + + @parameterized.named_parameters( + dict(testcase_name="without_solver", use_solver_class=False), + dict(testcase_name="with_solver", use_solver_class=True), + ) + def test_solution_hint_is_used(self, use_solver_class: bool) -> None: + model = _build_simple_model() + solution_hint = model_parameters_pb2.SolutionHintProto() + solution_hint.variable_values.ids.append(0) + solution_hint.variable_values.values.append(1.0) + # Limit the solver so that it does not find a solution other than provided. + result = _solve_model( + model, + use_solver_class=use_solver_class, + solver_type=parameters_pb2.SOLVER_TYPE_GSCIP, + parameters=parameters_pb2.SolveParametersProto( + node_limit=0, + heuristics=parameters_pb2.EMPHASIS_OFF, + presolve=parameters_pb2.EMPHASIS_OFF, + ), + model_parameters=model_parameters_pb2.ModelSolveParametersProto( + solution_hints=[solution_hint] + ), + ) + self.assertEqual( + result.termination.reason, result_pb2.TERMINATION_REASON_FEASIBLE + ) + self.assertTrue(result.solutions) + self.assertAlmostEqual(result.solutions[0].primal_solution.objective_value, 1.0) + + def test_debug_num_solver(self) -> None: + self.assertEqual(solver.debug_num_solver(), 0) + pybind_solver = solver.new( + parameters_pb2.SOLVER_TYPE_GLOP, + model_pb2.ModelProto(), + parameters_pb2.SolverInitializerProto(), + ) + self.assertEqual(solver.debug_num_solver(), 1) + del pybind_solver + self.assertEqual(solver.debug_num_solver(), 0) + + def test_incremental_solver_update(self) -> None: + model = _build_simple_model() + incremental_solver = solver.new( + parameters_pb2.SOLVER_TYPE_GLOP, + model, + parameters_pb2.SolverInitializerProto(), + ) + result = incremental_solver.solve( + parameters_pb2.SolveParametersProto(), + model_parameters_pb2.ModelSolveParametersProto(), + None, + callback_pb2.CallbackRegistrationProto(), + None, + None, + ) + self.assertEqual( + result.termination.reason, result_pb2.TERMINATION_REASON_OPTIMAL + ) + self.assertAlmostEqual(result.solve_stats.best_primal_bound, 2.0) + update = model_update_pb2.ModelUpdateProto() + update.variable_updates.upper_bounds.ids.append(0) + update.variable_updates.upper_bounds.values.append(2.5) + self.assertTrue(incremental_solver.update(update)) + result = incremental_solver.solve( + parameters_pb2.SolveParametersProto(), + model_parameters_pb2.ModelSolveParametersProto(), + None, + callback_pb2.CallbackRegistrationProto(), + None, + None, + ) + self.assertEqual( + result.termination.reason, result_pb2.TERMINATION_REASON_OPTIMAL + ) + self.assertAlmostEqual(result.solve_stats.best_primal_bound, 2.5) + + +class PybindSolveInterrupterTest(parameterized.TestCase): + def test_solve_interrupter_is_interrupted(self) -> None: + interrupter = solver.SolveInterrupter() + self.assertFalse(interrupter.is_interrupted()) + interrupter.interrupt() + self.assertTrue(interrupter.is_interrupted()) + del interrupter + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/BUILD.bazel b/ortools/math_opt/python/BUILD.bazel new file mode 100644 index 0000000000..957b324d49 --- /dev/null +++ b/ortools/math_opt/python/BUILD.bazel @@ -0,0 +1,190 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@pip_deps//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_library") + +# External users should depend only on ":mathopt" and import "mathopt". +# Hence other libraries are private. +package(default_visibility = ["//visibility:private"]) + +py_library( + name = "mathopt", + srcs = ["mathopt.py"], + visibility = ["//visibility:public"], + deps = [ + ":callback", + ":compute_infeasible_subsystem_result", + ":hash_model_storage", + ":message_callback", + ":model", + ":model_parameters", + ":model_storage", + ":parameters", + ":result", + ":solution", + ":solve", + ":sparse_containers", + ], +) + +py_library( + name = "model_storage", + srcs = ["model_storage.py"], + visibility = ["//ortools/math_opt/python:__subpackages__"], + deps = [ + "//ortools/math_opt:model_py_pb2", + "//ortools/math_opt:model_update_py_pb2", + ], +) + +py_library( + name = "hash_model_storage", + srcs = ["hash_model_storage.py"], + deps = [ + ":model_storage", + "//ortools/math_opt:model_py_pb2", + "//ortools/math_opt:model_update_py_pb2", + "//ortools/math_opt:sparse_containers_py_pb2", + ], +) + +py_library( + name = "model", + srcs = ["model.py"], + deps = [ + ":hash_model_storage", + ":model_storage", + requirement("immutabledict"), + "//ortools/math_opt:model_py_pb2", + "//ortools/math_opt:model_update_py_pb2", + ], +) + +py_library( + name = "sparse_containers", + srcs = ["sparse_containers.py"], + deps = [ + ":model", + "//ortools/math_opt:sparse_containers_py_pb2", + ], +) + +py_library( + name = "solution", + srcs = ["solution.py"], + deps = [ + ":model", + ":sparse_containers", + "//ortools/math_opt:solution_py_pb2", + ], +) + +py_library( + name = "result", + srcs = ["result.py"], + deps = [ + ":model", + ":solution", + "//ortools/gscip:gscip_proto_py_pb2", + "//ortools/math_opt:result_py_pb2", + "//ortools/math_opt/solvers:osqp_py_pb2", + ], +) + +py_library( + name = "parameters", + srcs = ["parameters.py"], + deps = [ + "//ortools/glop:parameters_py_pb2", + "//ortools/gscip:gscip_proto_py_pb2", + "//ortools/math_opt:parameters_py_pb2", + "//ortools/math_opt/solvers:glpk_py_pb2", + "//ortools/math_opt/solvers:gurobi_py_pb2", + "//ortools/math_opt/solvers:highs_py_pb2", + "//ortools/math_opt/solvers:osqp_py_pb2", + "//ortools/pdlp:solvers_py_pb2", + "//ortools/sat:sat_parameters_py_pb2", + ], +) + +py_library( + name = "model_parameters", + srcs = ["model_parameters.py"], + deps = [ + ":model", + ":solution", + ":sparse_containers", + "//ortools/math_opt:model_parameters_py_pb2", + ], +) + +py_library( + name = "callback", + srcs = ["callback.py"], + deps = [ + ":model", + ":sparse_containers", + "//ortools/math_opt:callback_py_pb2", + ], +) + +py_library( + name = "compute_infeasible_subsystem_result", + srcs = ["compute_infeasible_subsystem_result.py"], + deps = [ + ":model", + ":result", + requirement("immutabledict"), + "//ortools/math_opt:infeasible_subsystem_py_pb2", + ], +) + +py_library( + name = "solve", + srcs = ["solve.py"], + deps = [ + ":callback", + ":compute_infeasible_subsystem_result", + ":message_callback", + ":model", + ":model_parameters", + ":parameters", + ":result", + "//ortools/math_opt:parameters_py_pb2", + "//ortools/math_opt/core/python:solver", + ], +) + +py_library( + name = "message_callback", + srcs = ["message_callback.py"], + srcs_version = "PY3", + deps = [requirement("absl-py")], +) + +py_library( + name = "statistics", + srcs = ["statistics.py"], + deps = [":model"], +) + +py_library( + name = "normalize", + srcs = ["normalize.py"], + visibility = ["//ortools/math_opt/python:__subpackages__"], + deps = [ +# "@com_google_protobuf//protobuf:duration_py_pb2", + "@com_google_protobuf//:protobuf_python", + ], +) diff --git a/ortools/math_opt/python/callback.py b/ortools/math_opt/python/callback.py new file mode 100644 index 0000000000..ac511f8539 --- /dev/null +++ b/ortools/math_opt/python/callback.py @@ -0,0 +1,346 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Defines how to request a callback and the input and output of a callback.""" +import dataclasses +import datetime +import enum +import math +from typing import Dict, List, Mapping, Optional, Set, Union + +from ortools.math_opt import callback_pb2 +from ortools.math_opt.python import model +from ortools.math_opt.python import sparse_containers + + +@enum.unique +class Event(enum.Enum): + """The supported events during a solve for callbacks. + + * UNSPECIFIED: The event is unknown (typically an internal error). + * PRESOLVE: The solver is currently running presolve. Gurobi only. + * SIMPLEX: The solver is currently running the simplex method. Gurobi only. + * MIP: The solver is in the MIP loop (called periodically before starting a + new node). Useful for early termination. Note that this event does not + provide information on LP relaxations nor about new incumbent solutions. + Gurobi only. + * MIP_SOLUTION: Called every time a new MIP incumbent is found. Fully + supported by Gurobi, partially supported by CP-SAT (you can observe new + solutions, but not add lazy constraints). + * MIP_NODE: Called inside a MIP node. Note that there is no guarantee that the + callback function will be called on every node. That behavior is + solver-dependent. Gurobi only. + + Disabling cuts using SolveParameters may interfere with this event being + called and/or adding cuts at this event, the behavior is solver specific. + * BARRIER: Called in each iterate of an interior point/barrier method. Gurobi + only. + """ + + UNSPECIFIED = callback_pb2.CALLBACK_EVENT_UNSPECIFIED + PRESOLVE = callback_pb2.CALLBACK_EVENT_PRESOLVE + SIMPLEX = callback_pb2.CALLBACK_EVENT_SIMPLEX + MIP = callback_pb2.CALLBACK_EVENT_MIP + MIP_SOLUTION = callback_pb2.CALLBACK_EVENT_MIP_SOLUTION + MIP_NODE = callback_pb2.CALLBACK_EVENT_MIP_NODE + BARRIER = callback_pb2.CALLBACK_EVENT_BARRIER + + +PresolveStats = callback_pb2.CallbackDataProto.PresolveStats +SimplexStats = callback_pb2.CallbackDataProto.SimplexStats +BarrierStats = callback_pb2.CallbackDataProto.BarrierStats +MipStats = callback_pb2.CallbackDataProto.MipStats + + +@dataclasses.dataclass +class CallbackData: + """Input to the solve callback (produced by the solver). + + Attributes: + event: The current state of the solver when the callback is run. The event + (partially) determines what data is available and what the user is allowed + to return. + solution: A solution to the primal optimization problem, if available. For + Event.MIP_SOLUTION, solution is always present, integral, and feasible. + For Event.MIP_NODE, the primal_solution contains the current LP-node + relaxation. In some cases, no solution will be available (e.g. because LP + was infeasible or the solve was imprecise). Empty for other events. + messages: Logs generated by the underlying solver, as a list of strings + without new lines (each string is a line). Only filled on Event.MESSAGE. + runtime: The time since Solve() was invoked. + presolve_stats: Filled for Event.PRESOLVE only. + simplex_stats: Filled for Event.SIMPLEX only. + barrier_stats: Filled for Event.BARRIER only. + mip_stats: Filled for the events MIP, MIP_SOLUTION and MIP_NODE only. + """ + + event: Event = Event.UNSPECIFIED + solution: Optional[Dict[model.Variable, float]] = None + messages: List[str] = dataclasses.field(default_factory=list) + runtime: datetime.timedelta = datetime.timedelta() + presolve_stats: PresolveStats = dataclasses.field(default_factory=PresolveStats) + simplex_stats: SimplexStats = dataclasses.field(default_factory=SimplexStats) + barrier_stats: BarrierStats = dataclasses.field(default_factory=BarrierStats) + mip_stats: MipStats = dataclasses.field(default_factory=MipStats) + + +def parse_callback_data( + cb_data: callback_pb2.CallbackDataProto, mod: model.Model +) -> CallbackData: + """Creates a CallbackData from an equivalent proto. + + Args: + cb_data: A protocol buffer with the information the user needs for a + callback. + mod: The model being solved. + + Returns: + An equivalent CallbackData. + + Raises: + ValueError: if cb_data is invalid or inconsistent with mod, e.g. cb_data + refers to a variable id not in mod. + """ + result = CallbackData() + result.event = Event(cb_data.event) + if cb_data.HasField("primal_solution_vector"): + primal_solution = cb_data.primal_solution_vector + result.solution = { + mod.get_variable(id): val + for (id, val) in zip(primal_solution.ids, primal_solution.values) + } + result.runtime = cb_data.runtime.ToTimedelta() + result.presolve_stats = cb_data.presolve_stats + result.simplex_stats = cb_data.simplex_stats + result.barrier_stats = cb_data.barrier_stats + result.mip_stats = cb_data.mip_stats + return result + + +@dataclasses.dataclass +class CallbackRegistration: + """Request the events and input data and reports output types for a callback. + + Note that it is an error to add a constraint in a callback without setting + add_cuts and/or add_lazy_constraints to true. + + Attributes: + events: When the callback should be invoked, by default, never. If an + unsupported event for a solver/model combination is selected, an + excecption is raised, see Event above for details. + mip_solution_filter: restricts the variable values returned in + CallbackData.solution (the callback argument) at each MIP_SOLUTION event. + By default, values are returned for all variables. + mip_node_filter: restricts the variable values returned in + CallbackData.solution (the callback argument) at each MIP_NODE event. By + default, values are returned for all variables. + add_cuts: The callback may add "user cuts" (linear constraints that + strengthen the LP without cutting of integer points) at MIP_NODE events. + add_lazy_constraints: The callback may add "lazy constraints" (linear + constraints that cut off integer solutions) at MIP_NODE or MIP_SOLUTION + events. + """ + + events: Set[Event] = dataclasses.field(default_factory=set) + mip_solution_filter: sparse_containers.VariableFilter = ( + sparse_containers.VariableFilter() + ) + mip_node_filter: sparse_containers.VariableFilter = ( + sparse_containers.VariableFilter() + ) + add_cuts: bool = False + add_lazy_constraints: bool = False + + def to_proto(self) -> callback_pb2.CallbackRegistrationProto: + """Returns an equivalent proto to this CallbackRegistration.""" + result = callback_pb2.CallbackRegistrationProto() + result.request_registration[:] = sorted([event.value for event in self.events]) + result.mip_solution_filter.CopyFrom(self.mip_solution_filter.to_proto()) + result.mip_node_filter.CopyFrom(self.mip_node_filter.to_proto()) + result.add_cuts = self.add_cuts + result.add_lazy_constraints = self.add_lazy_constraints + return result + + +@dataclasses.dataclass +class GeneratedConstraint: + """A linear constraint to add inside a callback. + + Models a constraint of the form: + lb <= sum_{i in I} a_i * x_i <= ub + + Two types of generated linear constraints are supported based on is_lazy: + * The "lazy constraint" can remove integer points from the feasible + region and can be added at event Event.MIP_NODE or + Event.MIP_SOLUTION + * The "user cut" (on is_lazy=false) strengthens the LP without removing + integer points. It can only be added at Event.MIP_NODE. + + + Attributes: + terms: The variables and linear coefficients in the constraint, a_i and x_i + in the model above. + lower_bound: lb in the model above. + upper_bound: ub in the model above. + is_lazy: Indicates if the constraint should be interpreted as a "lazy + constraint" (cuts off integer solutions) or a "user cut" (strengthens the + LP relaxation without cutting of integer solutions). + """ + + terms: Mapping[model.Variable, float] = dataclasses.field(default_factory=dict) + lower_bound: float = -math.inf + upper_bound: float = math.inf + is_lazy: bool = False + + def to_proto( + self, + ) -> callback_pb2.CallbackResultProto.GeneratedLinearConstraint: + """Returns an equivalent proto for the constraint.""" + result = callback_pb2.CallbackResultProto.GeneratedLinearConstraint() + result.is_lazy = self.is_lazy + result.lower_bound = self.lower_bound + result.upper_bound = self.upper_bound + result.linear_expression.CopyFrom( + sparse_containers.to_sparse_double_vector_proto(self.terms) + ) + return result + + +@dataclasses.dataclass +class CallbackResult: + """The value returned by a solve callback (produced by the user). + + Attributes: + terminate: Stop the solve process and return early. Can be called from any + event. + generated_constraints: Constraints to add to the model. For details, see + GeneratedConstraint documentation. + suggested_solutions: A list of solutions (or partially defined solutions) to + suggest to the solver. Some solvers (e.g. gurobi) will try and convert a + partial solution into a full solution by solving a MIP. Use only for + Event.MIP_NODE. + """ + + terminate: bool = False + generated_constraints: List[GeneratedConstraint] = dataclasses.field( + default_factory=list + ) + suggested_solutions: List[Mapping[model.Variable, float]] = dataclasses.field( + default_factory=list + ) + + def add_generated_constraint( + self, + bounded_expr: Optional[Union[bool, model.BoundedLinearTypes]] = None, + *, + lb: Optional[float] = None, + ub: Optional[float] = None, + expr: Optional[model.LinearTypes] = None, + is_lazy: bool, + ) -> None: + """Adds a linear constraint to the list of generated constraints. + + The constraint can be of two exclusive types: a "lazy constraint" or a + "user cut. A "user cut" is a constraint that excludes the current LP + solution, but does not cut off any integer-feasible points that satisfy the + already added constraints (either in callbacks or through + Model.add_linear_constraint()). A "lazy constraint" is a constraint that + excludes such integer-feasible points and hence is needed for corrctness of + the forlumation. + + The simplest way to specify the constraint is by passing a one-sided or + two-sided linear inequality as in: + * add_generated_constraint(x + y + 1.0 <= 2.0, is_lazy=True), + * add_generated_constraint(x + y >= 2.0, is_lazy=True), or + * add_generated_constraint((1.0 <= x + y) <= 2.0, is_lazy=True). + + Note the extra parenthesis for two-sided linear inequalities, which is + required due to some language limitations (see + https://peps.python.org/pep-0335/ and https://peps.python.org/pep-0535/). + If the parenthesis are omitted, a TypeError will be raised explaining the + issue (if this error was not raised the first inequality would have been + silently ignored because of the noted language limitations). + + The second way to specify the constraint is by setting lb, ub, and/o expr as + in: + * add_generated_constraint(expr=x + y + 1.0, ub=2.0, is_lazy=True), + * add_generated_constraint(expr=x + y, lb=2.0, is_lazy=True), + * add_generated_constraint(expr=x + y, lb=1.0, ub=2.0, is_lazy=True), or + * add_generated_constraint(lb=1.0, is_lazy=True). + Omitting lb is equivalent to setting it to -math.inf and omiting ub is + equivalent to setting it to math.inf. + + These two alternatives are exclusive and a combined call like: + * add_generated_constraint(x + y <= 2.0, lb=1.0, is_lazy=True), or + * add_generated_constraint(x + y <= 2.0, ub=math.inf, is_lazy=True) + will raise a ValueError. A ValueError is also raised if expr's offset is + infinite. + + Args: + bounded_expr: a linear inequality describing the constraint. Cannot be + specified together with lb, ub, or expr. + lb: The constraint's lower bound if bounded_expr is omitted (if both + bounder_expr and lb are omitted, the lower bound is -math.inf). + ub: The constraint's upper bound if bounded_expr is omitted (if both + bounder_expr and ub are omitted, the upper bound is math.inf). + expr: The constraint's linear expression if bounded_expr is omitted. + is_lazy: Whether the constraint is lazy or not. + """ + normalized_inequality = model.as_normalized_linear_inequality( + bounded_expr, lb=lb, ub=ub, expr=expr + ) + self.generated_constraints.append( + GeneratedConstraint( + lower_bound=normalized_inequality.lb, + terms=normalized_inequality.coefficients, + upper_bound=normalized_inequality.ub, + is_lazy=is_lazy, + ) + ) + + def add_lazy_constraint( + self, + bounded_expr: Optional[Union[bool, model.BoundedLinearTypes]] = None, + *, + lb: Optional[float] = None, + ub: Optional[float] = None, + expr: Optional[model.LinearTypes] = None, + ) -> None: + """Shortcut for add_generated_constraint(..., is_lazy=True)..""" + self.add_generated_constraint( + bounded_expr, lb=lb, ub=ub, expr=expr, is_lazy=True + ) + + def add_user_cut( + self, + bounded_expr: Optional[Union[bool, model.BoundedLinearTypes]] = None, + *, + lb: Optional[float] = None, + ub: Optional[float] = None, + expr: Optional[model.LinearTypes] = None, + ) -> None: + """Shortcut for add_generated_constraint(..., is_lazy=False).""" + self.add_generated_constraint( + bounded_expr, lb=lb, ub=ub, expr=expr, is_lazy=False + ) + + def to_proto(self) -> callback_pb2.CallbackResultProto: + """Returns a proto equivalent to this CallbackResult.""" + result = callback_pb2.CallbackResultProto(terminate=self.terminate) + for generated_constraint in self.generated_constraints: + result.cuts.add().CopyFrom(generated_constraint.to_proto()) + for suggested_solution in self.suggested_solutions: + result.suggested_solutions.add().CopyFrom( + sparse_containers.to_sparse_double_vector_proto(suggested_solution) + ) + return result diff --git a/ortools/math_opt/python/callback_test.py b/ortools/math_opt/python/callback_test.py new file mode 100644 index 0000000000..e46b394a4a --- /dev/null +++ b/ortools/math_opt/python/callback_test.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import math + +import unittest +from ortools.math_opt import callback_pb2 +from ortools.math_opt import sparse_containers_pb2 +from ortools.math_opt.python import callback +from ortools.math_opt.python import model +from ortools.math_opt.python import sparse_containers +from ortools.math_opt.python.testing import compare_proto + + +class CallbackDataTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_parse_callback_data_no_solution(self) -> None: + mod = model.Model(name="test_model") + cb_data_proto = callback_pb2.CallbackDataProto( + event=callback_pb2.CALLBACK_EVENT_PRESOLVE + ) + cb_data_proto.runtime.FromTimedelta(datetime.timedelta(seconds=16.0)) + cb_data_proto.presolve_stats.removed_variables = 10 + cb_data_proto.simplex_stats.iteration_count = 3 + cb_data_proto.barrier_stats.primal_objective = 2.0 + cb_data_proto.mip_stats.open_nodes = 5 + cb_data = callback.parse_callback_data(cb_data_proto, mod) + self.assertEqual(cb_data.event, callback.Event.PRESOLVE) + self.assertIsNone(cb_data.solution) + self.assertEqual(16.0, cb_data.runtime.seconds) + self.assert_protos_equiv( + cb_data.presolve_stats, + callback_pb2.CallbackDataProto.PresolveStats(removed_variables=10), + ) + self.assert_protos_equiv( + cb_data.simplex_stats, + callback_pb2.CallbackDataProto.SimplexStats(iteration_count=3), + ) + self.assert_protos_equiv( + cb_data.barrier_stats, + callback_pb2.CallbackDataProto.BarrierStats(primal_objective=2.0), + ) + self.assert_protos_equiv( + cb_data.mip_stats, callback_pb2.CallbackDataProto.MipStats(open_nodes=5) + ) + + def test_parse_callback_data_with_solution(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + cb_data_proto = callback_pb2.CallbackDataProto( + event=callback_pb2.CALLBACK_EVENT_MIP_SOLUTION + ) + solution = cb_data_proto.primal_solution_vector + solution.ids[:] = [0, 1] + solution.values[:] = [0.0, 1.0] + cb_data_proto.runtime.FromTimedelta(datetime.timedelta(seconds=12.0)) + cb_data = callback.parse_callback_data(cb_data_proto, mod) + self.assertEqual(cb_data.event, callback.Event.MIP_SOLUTION) + self.assertDictEqual(cb_data.solution, {x: 0.0, y: 1.0}) + self.assertListEqual(cb_data.messages, []) + self.assertEqual(12.0, cb_data.runtime.seconds) + self.assert_protos_equiv( + cb_data.presolve_stats, callback_pb2.CallbackDataProto.PresolveStats() + ) + self.assert_protos_equiv( + cb_data.simplex_stats, callback_pb2.CallbackDataProto.SimplexStats() + ) + self.assert_protos_equiv( + cb_data.barrier_stats, callback_pb2.CallbackDataProto.BarrierStats() + ) + self.assert_protos_equiv( + cb_data.mip_stats, callback_pb2.CallbackDataProto.MipStats() + ) + + +class CallbackRegistrationTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def testToProto(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + reg = callback.CallbackRegistration() + reg.events = {callback.Event.MIP_SOLUTION, callback.Event.MIP_NODE} + reg.mip_node_filter = sparse_containers.VariableFilter(filtered_items=(z, x)) + reg.mip_solution_filter = sparse_containers.VariableFilter( + skip_zero_values=True + ) + reg.add_lazy_constraints = True + reg.add_cuts = False + + self.assert_protos_equiv( + reg.to_proto(), + callback_pb2.CallbackRegistrationProto( + request_registration=[ + callback_pb2.CALLBACK_EVENT_MIP_SOLUTION, + callback_pb2.CALLBACK_EVENT_MIP_NODE, + ], + mip_node_filter=sparse_containers_pb2.SparseVectorFilterProto( + filter_by_ids=True, filtered_ids=[0, 2] + ), + mip_solution_filter=sparse_containers_pb2.SparseVectorFilterProto( + skip_zero_values=True + ), + add_lazy_constraints=True, + add_cuts=False, + ), + ) + + +class GeneratedLinearConstraintTest( + compare_proto.MathOptProtoAssertions, unittest.TestCase +): + def testToProto(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + gen_con = callback.GeneratedConstraint() + gen_con.terms = {x: 2.0, z: 4.0} + gen_con.upper_bound = 5.0 + gen_con.is_lazy = True + + self.assert_protos_equiv( + gen_con.to_proto(), + callback_pb2.CallbackResultProto.GeneratedLinearConstraint( + lower_bound=-math.inf, + upper_bound=5.0, + is_lazy=True, + linear_expression=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 2], values=[2.0, 4.0] + ), + ), + ) + + +class CallbackResultTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def testToProto(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + result = callback.CallbackResult() + result.terminate = True + # Test le/ge combinations to avoid mutants. + result.add_lazy_constraint(2 * x <= 0) + result.add_lazy_constraint(2 * x >= 0) + result.add_user_cut(2 * z >= 2) + result.add_user_cut(2 * z <= 2) + result.add_generated_constraint(expr=2 * z, lb=2, is_lazy=False) + result.add_generated_constraint(expr=2 * z, ub=2, is_lazy=False) + result.suggested_solutions.append({x: 1.0, y: 0.0, z: 1.0}) + result.suggested_solutions.append({x: 0.0, y: 0.0, z: 0.0}) + + expected = callback_pb2.CallbackResultProto( + terminate=True, + cuts=[ + callback_pb2.CallbackResultProto.GeneratedLinearConstraint( + lower_bound=-math.inf, + upper_bound=0.0, + is_lazy=True, + linear_expression=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[2.0] + ), + ), + callback_pb2.CallbackResultProto.GeneratedLinearConstraint( + lower_bound=0.0, + upper_bound=math.inf, + is_lazy=True, + linear_expression=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[2.0] + ), + ), + callback_pb2.CallbackResultProto.GeneratedLinearConstraint( + lower_bound=2.0, + upper_bound=math.inf, + is_lazy=False, + linear_expression=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[2], values=[2.0] + ), + ), + callback_pb2.CallbackResultProto.GeneratedLinearConstraint( + lower_bound=-math.inf, + upper_bound=2.0, + is_lazy=False, + linear_expression=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[2], values=[2.0] + ), + ), + callback_pb2.CallbackResultProto.GeneratedLinearConstraint( + lower_bound=2.0, + upper_bound=math.inf, + is_lazy=False, + linear_expression=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[2], values=[2.0] + ), + ), + callback_pb2.CallbackResultProto.GeneratedLinearConstraint( + lower_bound=-math.inf, + upper_bound=2.0, + is_lazy=False, + linear_expression=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[2], values=[2.0] + ), + ), + ], + suggested_solutions=[ + sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1, 2], values=[1.0, 0.0, 1.0] + ), + sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1, 2], values=[0.0, 0.0, 0.0] + ), + ], + ) + self.assert_protos_equiv(result.to_proto(), expected) + + def testConstraintErrors(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + result = callback.CallbackResult() + with self.assertRaisesRegex( + TypeError, + "unsupported operand.*\n.*two or more non-constant linear expressions", + ): + result.add_lazy_constraint(x <= (y <= z)) + with self.assertRaisesRegex(ValueError, "lb cannot be specified.*"): + result.add_user_cut(x + y == 1, lb=1) + + def testToProtoEmpty(self) -> None: + result = callback.CallbackResult() + self.assert_protos_equiv(result.to_proto(), callback_pb2.CallbackResultProto()) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/compute_infeasible_subsystem_result.py b/ortools/math_opt/python/compute_infeasible_subsystem_result.py new file mode 100644 index 0000000000..a227e1aaef --- /dev/null +++ b/ortools/math_opt/python/compute_infeasible_subsystem_result.py @@ -0,0 +1,197 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Data types for the result of calling `mathopt.compute_infeasible_subsystem.""" + +import dataclasses +from typing import Mapping + +import immutabledict + +from ortools.math_opt import infeasible_subsystem_pb2 +from ortools.math_opt.python import model +from ortools.math_opt.python import result + + +@dataclasses.dataclass(frozen=True) +class ModelSubsetBounds: + """Presence of the upper and lower bounds in a two-sided constraint. + + E.g. for 1 <= x <= 2, `lower` is the constraint 1 <= x and `upper` is the + constraint x <= 2. + + Attributes: + lower: If the lower bound half of the two-sided constraint is selected. + upper: If the upper bound half of the two-sided constraint is selected. + """ + + lower: bool = False + upper: bool = False + + def empty(self) -> bool: + """Is empty if both `lower` and `upper` are False.""" + return not (self.lower or self.upper) + + def to_proto(self) -> infeasible_subsystem_pb2.ModelSubsetProto.Bounds: + """Returns an equivalent proto message for these bounds.""" + return infeasible_subsystem_pb2.ModelSubsetProto.Bounds( + lower=self.lower, upper=self.upper + ) + + +def parse_model_subset_bounds( + bounds: infeasible_subsystem_pb2.ModelSubsetProto.Bounds, +) -> ModelSubsetBounds: + """Returns an equivalent `ModelSubsetBounds` to the input proto.""" + return ModelSubsetBounds(lower=bounds.lower, upper=bounds.upper) + + +@dataclasses.dataclass(frozen=True) +class ModelSubset: + """A subset of a Model's constraints (including variable bounds/integrality). + + When returned from `solve.compute_infeasible_subsystem`, the contained + `ModelSubsetBounds` will all be nonempty. + + Attributes: + variable_bounds: The upper and/or lower bound constraints on these variables + are included in the subset. + variable_integrality: The constraint that a variable is integer is included + in the subset. + linear_constraints: The upper and/or lower bounds from these linear + constraints are included in the subset. + """ + + variable_bounds: Mapping[ + model.Variable, ModelSubsetBounds + ] = immutabledict.immutabledict() + variable_integrality: frozenset[model.Variable] = frozenset() + linear_constraints: Mapping[ + model.LinearConstraint, ModelSubsetBounds + ] = immutabledict.immutabledict() + + def empty(self) -> bool: + """Returns true if all the nested constraint collections are empty. + + Warning: When `self.variable_bounds` or `self.linear_constraints` contain + only ModelSubsetBounds which are themselves empty, this function will return + False. + + Returns: + True if this is empty. + """ + return not ( + self.variable_bounds or self.variable_integrality or self.linear_constraints + ) + + def to_proto(self) -> infeasible_subsystem_pb2.ModelSubsetProto: + """Returns an equivalent proto message for this `ModelSubset`.""" + return infeasible_subsystem_pb2.ModelSubsetProto( + variable_bounds={ + var.id: bounds.to_proto() + for (var, bounds) in self.variable_bounds.items() + }, + variable_integrality=sorted(var.id for var in self.variable_integrality), + linear_constraints={ + con.id: bounds.to_proto() + for (con, bounds) in self.linear_constraints.items() + }, + ) + + +def parse_model_subset( + model_subset: infeasible_subsystem_pb2.ModelSubsetProto, mod: model.Model +) -> ModelSubset: + """Returns an equivalent `ModelSubset` to the input proto.""" + if model_subset.quadratic_constraints: + raise NotImplementedError( + "quadratic_constraints not yet implemented for ModelSubset in Python" + ) + if model_subset.second_order_cone_constraints: + raise NotImplementedError( + "second_order_cone_constraints not yet implemented for ModelSubset in" + " Python" + ) + if model_subset.sos1_constraints: + raise NotImplementedError( + "sos1_constraints not yet implemented for ModelSubset in Python" + ) + if model_subset.sos2_constraints: + raise NotImplementedError( + "sos2_constraints not yet implemented for ModelSubset in Python" + ) + if model_subset.indicator_constraints: + raise NotImplementedError( + "indicator_constraints not yet implemented for ModelSubset in Python" + ) + return ModelSubset( + variable_bounds={ + mod.get_variable(var_id): parse_model_subset_bounds(bounds) + for var_id, bounds in model_subset.variable_bounds.items() + }, + variable_integrality=frozenset( + mod.get_variable(var_id) for var_id in model_subset.variable_integrality + ), + linear_constraints={ + mod.get_linear_constraint(con_id): parse_model_subset_bounds(bounds) + for con_id, bounds in model_subset.linear_constraints.items() + }, + ) + + +@dataclasses.dataclass(frozen=True) +class ComputeInfeasibleSubsystemResult: + """The result of searching for an infeasible subsystem. + + This is the result of calling `mathopt.compute_infeasible_subsystem()`. + + Attributes: + feasibility: If the problem was proven feasible, infeasible, or no + conclusion was reached. The fields below are ignored unless the problem + was proven infeasible. + infeasible_subsystem: Ignored unless `feasibility` is `INFEASIBLE`, a subset + of the model that is still infeasible. + is_minimal: Ignored unless `feasibility` is `INFEASIBLE`. If True, then the + removal of any constraint from `infeasible_subsystem` makes the sub-model + feasible. Note that, due to problem transformations MathOpt applies or + idiosyncrasies of the solvers contract, the returned infeasible subsystem + may not actually be minimal. + """ + + feasibility: result.FeasibilityStatus = result.FeasibilityStatus.UNDETERMINED + infeasible_subsystem: ModelSubset = ModelSubset() + is_minimal: bool = False + + def to_proto( + self, + ) -> infeasible_subsystem_pb2.ComputeInfeasibleSubsystemResultProto: + """Returns an equivalent proto for this `ComputeInfeasibleSubsystemResult`.""" + return infeasible_subsystem_pb2.ComputeInfeasibleSubsystemResultProto( + feasibility=self.feasibility.value, + infeasible_subsystem=self.infeasible_subsystem.to_proto(), + is_minimal=self.is_minimal, + ) + + +def parse_compute_infeasible_subsystem_result( + infeasible_system_result: infeasible_subsystem_pb2.ComputeInfeasibleSubsystemResultProto, + mod: model.Model, +) -> ComputeInfeasibleSubsystemResult: + """Returns an equivalent `ComputeInfeasibleSubsystemResult` to the input proto.""" + return ComputeInfeasibleSubsystemResult( + feasibility=result.FeasibilityStatus(infeasible_system_result.feasibility), + infeasible_subsystem=parse_model_subset( + infeasible_system_result.infeasible_subsystem, mod + ), + is_minimal=infeasible_system_result.is_minimal, + ) diff --git a/ortools/math_opt/python/compute_infeasible_subsystem_result_test.py b/ortools/math_opt/python/compute_infeasible_subsystem_result_test.py new file mode 100644 index 0000000000..55b4ae61ab --- /dev/null +++ b/ortools/math_opt/python/compute_infeasible_subsystem_result_test.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for compute_infeasible_subsystem_result.py.""" + +import unittest +from ortools.math_opt import infeasible_subsystem_pb2 +from ortools.math_opt.python import compute_infeasible_subsystem_result +from ortools.math_opt.python import model +from ortools.math_opt.python import result +from ortools.math_opt.python.testing import compare_proto + +_ModelSubsetBounds = compute_infeasible_subsystem_result.ModelSubsetBounds +_ModelSubset = compute_infeasible_subsystem_result.ModelSubset +_ComputeInfeasibleSubsystemResult = ( + compute_infeasible_subsystem_result.ComputeInfeasibleSubsystemResult +) + + +class ModelSubsetBoundsTest(unittest.TestCase, compare_proto.MathOptProtoAssertions): + def test_empty(self) -> None: + self.assertTrue(_ModelSubsetBounds().empty()) + self.assertFalse(_ModelSubsetBounds(lower=True).empty()) + self.assertFalse(_ModelSubsetBounds(upper=True).empty()) + + def test_proto(self) -> None: + start_bounds = _ModelSubsetBounds(lower=True) + self.assert_protos_equiv( + start_bounds.to_proto(), + infeasible_subsystem_pb2.ModelSubsetProto.Bounds(lower=True), + ) + + def test_proto_round_trip_lower(self) -> None: + start_bounds = _ModelSubsetBounds(lower=True) + self.assertEqual( + compute_infeasible_subsystem_result.parse_model_subset_bounds( + start_bounds.to_proto() + ), + start_bounds, + ) + + def test_proto_round_trip_upper(self) -> None: + start_bounds = _ModelSubsetBounds(upper=True) + self.assertEqual( + compute_infeasible_subsystem_result.parse_model_subset_bounds( + start_bounds.to_proto() + ), + start_bounds, + ) + + +class ModelSubsetTest(unittest.TestCase, compare_proto.MathOptProtoAssertions): + def test_empty(self) -> None: + m = model.Model() + x = m.add_binary_variable() + c = m.add_linear_constraint() + self.assertTrue(_ModelSubset().empty()) + self.assertFalse(_ModelSubset(variable_integrality=frozenset((x,))).empty()) + self.assertFalse( + _ModelSubset(variable_bounds={x: _ModelSubsetBounds(lower=True)}).empty() + ) + self.assertFalse( + _ModelSubset(linear_constraints={c: _ModelSubsetBounds(upper=True)}).empty() + ) + + def test_to_proto(self) -> None: + m = model.Model() + x = m.add_binary_variable() + y = m.add_binary_variable() + c = m.add_linear_constraint() + d = m.add_linear_constraint() + model_subset = _ModelSubset( + variable_integrality=frozenset((x, y)), + variable_bounds={y: _ModelSubsetBounds(upper=True)}, + linear_constraints={ + c: _ModelSubsetBounds(upper=True), + d: _ModelSubsetBounds(lower=True), + }, + ) + expected = infeasible_subsystem_pb2.ModelSubsetProto() + expected.variable_bounds[1].upper = True + expected.variable_integrality[:] = [0, 1] + expected.linear_constraints[0].upper = True + expected.linear_constraints[1].lower = True + self.assert_protos_equiv(model_subset.to_proto(), expected) + + def test_proto_round_trip_empty(self) -> None: + m = model.Model() + subset = _ModelSubset() + self.assertEqual( + compute_infeasible_subsystem_result.parse_model_subset( + subset.to_proto(), m + ), + subset, + ) + + def test_proto_round_trip_full(self) -> None: + m = model.Model() + x = m.add_binary_variable() + y = m.add_binary_variable() + c = m.add_linear_constraint() + d = m.add_linear_constraint() + start_subset = _ModelSubset( + variable_integrality=frozenset((x,)), + variable_bounds={ + x: _ModelSubsetBounds(lower=True), + y: _ModelSubsetBounds(upper=True), + }, + linear_constraints={ + c: _ModelSubsetBounds(upper=True), + d: _ModelSubsetBounds(lower=True), + }, + ) + self.assertEqual( + compute_infeasible_subsystem_result.parse_model_subset( + start_subset.to_proto(), m + ), + start_subset, + ) + + def test_parse_proto_quadratic_constraint_unsupported(self) -> None: + m = model.Model() + model_subset = infeasible_subsystem_pb2.ModelSubsetProto() + model_subset.quadratic_constraints[3].lower = True + with self.assertRaisesRegex(NotImplementedError, "quadratic_constraints"): + compute_infeasible_subsystem_result.parse_model_subset(model_subset, m) + + def test_parse_proto_second_order_cone_unsupported(self) -> None: + m = model.Model() + model_subset = infeasible_subsystem_pb2.ModelSubsetProto( + second_order_cone_constraints=[2] + ) + with self.assertRaisesRegex( + NotImplementedError, "second_order_cone_constraints" + ): + compute_infeasible_subsystem_result.parse_model_subset(model_subset, m) + + def test_parse_proto_sos1_unsupported(self) -> None: + m = model.Model() + model_subset = infeasible_subsystem_pb2.ModelSubsetProto(sos1_constraints=[2]) + with self.assertRaisesRegex(NotImplementedError, "sos1_constraints"): + compute_infeasible_subsystem_result.parse_model_subset(model_subset, m) + + def test_parse_proto_sos2_unsupported(self) -> None: + m = model.Model() + model_subset = infeasible_subsystem_pb2.ModelSubsetProto(sos2_constraints=[2]) + with self.assertRaisesRegex(NotImplementedError, "sos2_constraints"): + compute_infeasible_subsystem_result.parse_model_subset(model_subset, m) + + def test_parse_proto_indicator_unsupported(self) -> None: + m = model.Model() + model_subset = infeasible_subsystem_pb2.ModelSubsetProto( + indicator_constraints=[2] + ) + with self.assertRaisesRegex(NotImplementedError, "indicator_constraints"): + compute_infeasible_subsystem_result.parse_model_subset(model_subset, m) + + +class ComputeInfeasibleSubsystemResultTest(unittest.TestCase): + def test_to_proto_round_trip(self) -> None: + m = model.Model() + x = m.add_binary_variable() + iis_result = _ComputeInfeasibleSubsystemResult( + feasibility=result.FeasibilityStatus.INFEASIBLE, + is_minimal=True, + infeasible_subsystem=_ModelSubset(variable_integrality=frozenset((x,))), + ) + self.assertEqual( + compute_infeasible_subsystem_result.parse_compute_infeasible_subsystem_result( + iis_result.to_proto(), m + ), + iis_result, + ) + + def test_to_proto_round_trip_empty(self) -> None: + m = model.Model() + iis_result = _ComputeInfeasibleSubsystemResult( + feasibility=result.FeasibilityStatus.UNDETERMINED + ) + self.assertEqual( + compute_infeasible_subsystem_result.parse_compute_infeasible_subsystem_result( + iis_result.to_proto(), m + ), + iis_result, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/hash_model_storage.py b/ortools/math_opt/python/hash_model_storage.py new file mode 100644 index 0000000000..64608f9bce --- /dev/null +++ b/ortools/math_opt/python/hash_model_storage.py @@ -0,0 +1,843 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A minimal pure python implementation of model_storage.ModelStorage.""" + +from typing import Dict, Iterable, Iterator, Optional, Set, Tuple +import weakref + +from ortools.math_opt import model_pb2 +from ortools.math_opt import model_update_pb2 +from ortools.math_opt import sparse_containers_pb2 +from ortools.math_opt.python import model_storage + +_QuadraticKey = model_storage.QuadraticTermIdKey + + +class _UpdateTracker(model_storage.StorageUpdateTracker): + """Tracks model updates for HashModelStorage.""" + + def __init__(self, mod: "HashModelStorage"): + self.retired: bool = False + self.model: "HashModelStorage" = mod + # Changes for variables with id < variables_checkpoint are explicitly + # tracked. + self.variables_checkpoint: int = self.model._next_var_id + # Changes for linear constraints with id < linear_constraints_checkpoint + # are explicitly tracked. + self.linear_constraints_checkpoint: int = self.model._next_lin_con_id + + self.objective_direction: bool = False + self.objective_offset: bool = False + + self.variable_deletes: Set[int] = set() + self.variable_lbs: Set[int] = set() + self.variable_ubs: Set[int] = set() + self.variable_integers: Set[int] = set() + + self.linear_objective_coefficients: Set[int] = set() + self.quadratic_objective_coefficients: Set[_QuadraticKey] = set() + + self.linear_constraint_deletes: Set[int] = set() + self.linear_constraint_lbs: Set[int] = set() + self.linear_constraint_ubs: Set[int] = set() + + self.linear_constraint_matrix: Set[Tuple[int, int]] = set() + + def export_update(self) -> Optional[model_update_pb2.ModelUpdateProto]: + if self.retired: + raise model_storage.UsedUpdateTrackerAfterRemovalError() + if ( + self.variables_checkpoint == self.model.next_variable_id() + and ( + self.linear_constraints_checkpoint + == self.model.next_linear_constraint_id() + ) + and not self.objective_direction + and not self.objective_offset + and not self.variable_deletes + and not self.variable_lbs + and not self.variable_ubs + and not self.variable_integers + and not self.linear_objective_coefficients + and not self.quadratic_objective_coefficients + and not self.linear_constraint_deletes + and not self.linear_constraint_lbs + and not self.linear_constraint_ubs + and not self.linear_constraint_matrix + ): + return None + result = model_update_pb2.ModelUpdateProto() + result.deleted_variable_ids[:] = sorted(self.variable_deletes) + result.deleted_linear_constraint_ids[:] = sorted(self.linear_constraint_deletes) + # Variable updates + _set_sparse_double_vector( + sorted((vid, self.model.get_variable_lb(vid)) for vid in self.variable_lbs), + result.variable_updates.lower_bounds, + ) + _set_sparse_double_vector( + sorted((vid, self.model.get_variable_ub(vid)) for vid in self.variable_ubs), + result.variable_updates.upper_bounds, + ) + _set_sparse_bool_vector( + sorted( + (vid, self.model.get_variable_is_integer(vid)) + for vid in self.variable_integers + ), + result.variable_updates.integers, + ) + # Linear constraint updates + _set_sparse_double_vector( + sorted( + (cid, self.model.get_linear_constraint_lb(cid)) + for cid in self.linear_constraint_lbs + ), + result.linear_constraint_updates.lower_bounds, + ) + _set_sparse_double_vector( + sorted( + (cid, self.model.get_linear_constraint_ub(cid)) + for cid in self.linear_constraint_ubs + ), + result.linear_constraint_updates.upper_bounds, + ) + # New variables and constraints + new_vars = [] + for vid in range(self.variables_checkpoint, self.model.next_variable_id()): + var = self.model.variables.get(vid) + if var is not None: + new_vars.append((vid, var)) + _variables_to_proto(new_vars, result.new_variables) + new_lin_cons = [] + for lin_con_id in range( + self.linear_constraints_checkpoint, + self.model.next_linear_constraint_id(), + ): + lin_con = self.model.linear_constraints.get(lin_con_id) + if lin_con is not None: + new_lin_cons.append((lin_con_id, lin_con)) + _linear_constraints_to_proto(new_lin_cons, result.new_linear_constraints) + # Objective update + if self.objective_direction: + result.objective_updates.direction_update = self.model.get_is_maximize() + if self.objective_offset: + result.objective_updates.offset_update = self.model.get_objective_offset() + _set_sparse_double_vector( + sorted( + (var, self.model.get_linear_objective_coefficient(var)) + for var in self.linear_objective_coefficients + ), + result.objective_updates.linear_coefficients, + ) + for new_var in range(self.variables_checkpoint, self.model.next_variable_id()): + # NOTE: the value will be 0.0 if either the coefficient is not set or the + # variable has been deleted. Calling + # model.get_linear_objective_coefficient() throws an exception if the + # variable has been deleted. + obj_coef = self.model.linear_objective_coefficient.get(new_var, 0.0) + if obj_coef: + result.objective_updates.linear_coefficients.ids.append(new_var) + result.objective_updates.linear_coefficients.values.append(obj_coef) + + quadratic_objective_updates = [ + ( + key.id1, + key.id2, + self.model.get_quadratic_objective_coefficient(key.id1, key.id2), + ) + for key in self.quadratic_objective_coefficients + ] + for new_var in range(self.variables_checkpoint, self.model.next_variable_id()): + if self.model.variable_exists(new_var): + for other_var in self.model.get_quadratic_objective_adjacent_variables( + new_var + ): + key = _QuadraticKey(new_var, other_var) + if new_var >= other_var: + key = _QuadraticKey(new_var, other_var) + quadratic_objective_updates.append( + ( + key.id1, + key.id2, + self.model.get_quadratic_objective_coefficient( + key.id1, key.id2 + ), + ) + ) + quadratic_objective_updates.sort() + if quadratic_objective_updates: + first_var_ids, second_var_ids, coefficients = zip( + *quadratic_objective_updates + ) + result.objective_updates.quadratic_coefficients.row_ids[:] = first_var_ids + result.objective_updates.quadratic_coefficients.column_ids[ + : + ] = second_var_ids + result.objective_updates.quadratic_coefficients.coefficients[ + : + ] = coefficients + # Linear constraint matrix updates + matrix_updates = [ + (l, v, self.model.get_linear_constraint_coefficient(l, v)) + for (l, v) in self.linear_constraint_matrix + ] + for new_var in range(self.variables_checkpoint, self.model.next_variable_id()): + if self.model.variable_exists(new_var): + for lin_con in self.model.get_linear_constraints_with_variable(new_var): + matrix_updates.append( + ( + lin_con, + new_var, + self.model.get_linear_constraint_coefficient( + lin_con, new_var + ), + ) + ) + for new_lin_con in range( + self.linear_constraints_checkpoint, + self.model.next_linear_constraint_id(), + ): + if self.model.linear_constraint_exists(new_lin_con): + for var in self.model.get_variables_for_linear_constraint(new_lin_con): + # We have already gotten the new variables above. Note that we do at + # most twice as much work as we should from this. + if var < self.variables_checkpoint: + matrix_updates.append( + ( + new_lin_con, + var, + self.model.get_linear_constraint_coefficient( + new_lin_con, var + ), + ) + ) + matrix_updates.sort() + if matrix_updates: + lin_cons, variables, coefs = zip(*matrix_updates) + result.linear_constraint_matrix_updates.row_ids[:] = lin_cons + result.linear_constraint_matrix_updates.column_ids[:] = variables + result.linear_constraint_matrix_updates.coefficients[:] = coefs + return result + + def advance_checkpoint(self) -> None: + if self.retired: + raise model_storage.UsedUpdateTrackerAfterRemovalError() + self.objective_direction = False + self.objective_offset = False + self.variable_deletes = set() + self.variable_lbs = set() + self.variable_ubs = set() + self.variable_integers = set() + self.linear_objective_coefficients = set() + self.linear_constraint_deletes = set() + self.linear_constraint_lbs = set() + self.linear_constraint_ubs = set() + self.linear_constraint_matrix = set() + + self.variables_checkpoint = self.model.next_variable_id() + self.linear_constraints_checkpoint = self.model.next_linear_constraint_id() + + +class _VariableStorage: + """Data specific to each decision variable in the optimization problem.""" + + def __init__(self, lb: float, ub: float, is_integer: bool, name: str) -> None: + self.lower_bound: float = lb + self.upper_bound: float = ub + self.is_integer: bool = is_integer + self.name: str = name + self.linear_constraint_nonzeros: Set[int] = set() + + +class _LinearConstraintStorage: + """Data specific to each linear constraint in the optimization problem.""" + + def __init__(self, lb: float, ub: float, name: str) -> None: + self.lower_bound: float = lb + self.upper_bound: float = ub + self.name: str = name + self.variable_nonzeros: Set[int] = set() + + +class _QuadraticTermStorage: + """Data describing quadratic terms with non-zero coefficients.""" + + def __init__(self) -> None: + self._coefficients: Dict[_QuadraticKey, float] = {} + # For a variable i that does not appear in a quadratic objective term with + # a non-zero coefficient, we may have self._adjacent_variable[i] being an + # empty set or i not appearing in self._adjacent_variable.keys() (e.g. + # depeding on whether the variable previously appeared in a quadratic term). + self._adjacent_variables: Dict[int, Set[int]] = {} + + def __bool__(self) -> bool: + """Returns true if and only if there are any quadratic terms with non-zero coefficients.""" + return bool(self._coefficients) + + def get_adjacent_variables(self, variable_id: int) -> Iterator[int]: + """Yields the variables multiplying a variable in the stored quadratic terms. + + If variable_id is not in the model the function yields the empty set. + + Args: + variable_id: Function yields the variables multiplying variable_id in the + stored quadratic terms. + + Yields: + The variables multiplying variable_id in the stored quadratic terms. + """ + yield from self._adjacent_variables.get(variable_id, ()) + + def keys(self) -> Iterator[_QuadraticKey]: + """Yields the variable-pair keys associated to the stored quadratic terms.""" + yield from self._coefficients.keys() + + def coefficients(self) -> Iterator[model_storage.QuadraticEntry]: + """Yields the stored quadratic terms as QuadraticEntry.""" + for key, coef in self._coefficients.items(): + yield model_storage.QuadraticEntry(id_key=key, coefficient=coef) + + def delete_variable(self, variable_id: int) -> None: + """Updates the data structure to consider variable_id as deleted.""" + if variable_id not in self._adjacent_variables.keys(): + return + for adjacent_variable_id in self._adjacent_variables[variable_id]: + if variable_id != adjacent_variable_id: + self._adjacent_variables[adjacent_variable_id].remove(variable_id) + del self._coefficients[_QuadraticKey(variable_id, adjacent_variable_id)] + self._adjacent_variables[variable_id].clear() + + def clear(self) -> None: + """Clears the data structure.""" + self._coefficients.clear() + self._adjacent_variables.clear() + + def set_coefficient( + self, first_variable_id: int, second_variable_id: int, value: float + ) -> bool: + """Sets the coefficient for the quadratic term associated to the product between two variables. + + The ordering of the input variables does not matter. + + Args: + first_variable_id: The first variable in the product. + second_variable_id: The second variable in the product. + value: The value of the coefficient. + + Returns: + True if the coefficient is updated, False otherwise. + """ + key = _QuadraticKey(first_variable_id, second_variable_id) + if value == self._coefficients.get(key, 0.0): + return False + if value == 0.0: + # Assuming self._coefficients/_adjacent_variables are filled according + # to get_coefficient(key) != 0.0. + del self._coefficients[key] + self._adjacent_variables[first_variable_id].remove(second_variable_id) + if first_variable_id != second_variable_id: + self._adjacent_variables[second_variable_id].remove(first_variable_id) + else: + if first_variable_id not in self._adjacent_variables.keys(): + self._adjacent_variables[first_variable_id] = set() + if second_variable_id not in self._adjacent_variables.keys(): + self._adjacent_variables[second_variable_id] = set() + self._coefficients[key] = value + self._adjacent_variables[first_variable_id].add(second_variable_id) + self._adjacent_variables[second_variable_id].add(first_variable_id) + return True + + def get_coefficient(self, first_variable_id: int, second_variable_id: int) -> float: + """Gets the objective coefficient for the quadratic term associated to the product between two variables. + + The ordering of the input variables does not matter. + + Args: + first_variable_id: The first variable in the product. + second_variable_id: The second variable in the product. + + Returns: + The value of the coefficient. + """ + return self._coefficients.get( + _QuadraticKey(first_variable_id, second_variable_id), 0.0 + ) + + +class HashModelStorage(model_storage.ModelStorage): + """A simple, pure python implementation of ModelStorage. + + Attributes: + _linear_constraint_matrix: A dictionary with (linear_constraint_id, + variable_id) keys and numeric values, representing the matrix A for the + constraints lb_c <= A*x <= ub_c. Invariant: the values have no zeros. + linear_objective_coefficient: A dictionary with variable_id keys and + numeric values, representing the linear terms in the objective. + Invariant: the values have no zeros. + _quadratic_objective_coefficients: A data structure containing quadratic + terms in the objective. + """ + + def __init__(self, name: str = "") -> None: + super().__init__() + self._name: str = name + self.variables: Dict[int, _VariableStorage] = {} + self.linear_constraints: Dict[int, _LinearConstraintStorage] = {} + self._linear_constraint_matrix: Dict[Tuple[int, int], float] = {} # + self._is_maximize: bool = False + self._objective_offset: float = 0.0 + self.linear_objective_coefficient: Dict[int, float] = {} + self._quadratic_objective_coefficients: _QuadraticTermStorage = ( + _QuadraticTermStorage() + ) + self._next_var_id: int = 0 + self._next_lin_con_id: int = 0 + self._update_trackers: weakref.WeakSet[_UpdateTracker] = weakref.WeakSet() + + @property + def name(self) -> str: + return self._name + + def add_variable(self, lb: float, ub: float, is_integer: bool, name: str) -> int: + var_id = self._next_var_id + self._next_var_id += 1 + self.variables[var_id] = _VariableStorage(lb, ub, is_integer, name) + return var_id + + def delete_variable(self, variable_id: int) -> None: + self._check_variable_id(variable_id) + variable = self.variables[variable_id] + # First update the watchers + for watcher in self._update_trackers: + if variable_id < watcher.variables_checkpoint: + watcher.variable_deletes.add(variable_id) + watcher.variable_lbs.discard(variable_id) + watcher.variable_ubs.discard(variable_id) + watcher.variable_integers.discard(variable_id) + watcher.linear_objective_coefficients.discard(variable_id) + for ( + other_variable_id + ) in self._quadratic_objective_coefficients.get_adjacent_variables( + variable_id + ): + key = _QuadraticKey(variable_id, other_variable_id) + watcher.quadratic_objective_coefficients.discard(key) + for lin_con_id in variable.linear_constraint_nonzeros: + if lin_con_id < watcher.linear_constraints_checkpoint: + watcher.linear_constraint_matrix.discard( + (lin_con_id, variable_id) + ) + # Then update self. + for lin_con_id in variable.linear_constraint_nonzeros: + self.linear_constraints[lin_con_id].variable_nonzeros.remove(variable_id) + del self._linear_constraint_matrix[(lin_con_id, variable_id)] + del self.variables[variable_id] + self.linear_objective_coefficient.pop(variable_id, None) + self._quadratic_objective_coefficients.delete_variable(variable_id) + + def variable_exists(self, variable_id: int) -> bool: + return variable_id in self.variables + + def next_variable_id(self) -> int: + return self._next_var_id + + def set_variable_lb(self, variable_id: int, lb: float) -> None: + self._check_variable_id(variable_id) + if lb == self.variables[variable_id].lower_bound: + return + self.variables[variable_id].lower_bound = lb + for watcher in self._update_trackers: + if variable_id < watcher.variables_checkpoint: + watcher.variable_lbs.add(variable_id) + + def set_variable_ub(self, variable_id: int, ub: float) -> None: + self._check_variable_id(variable_id) + if ub == self.variables[variable_id].upper_bound: + return + self.variables[variable_id].upper_bound = ub + for watcher in self._update_trackers: + if variable_id < watcher.variables_checkpoint: + watcher.variable_ubs.add(variable_id) + + def set_variable_is_integer(self, variable_id: int, is_integer: bool) -> None: + self._check_variable_id(variable_id) + if is_integer == self.variables[variable_id].is_integer: + return + self.variables[variable_id].is_integer = is_integer + for watcher in self._update_trackers: + if variable_id < watcher.variables_checkpoint: + watcher.variable_integers.add(variable_id) + + def get_variable_lb(self, variable_id: int) -> float: + self._check_variable_id(variable_id) + return self.variables[variable_id].lower_bound + + def get_variable_ub(self, variable_id: int) -> float: + self._check_variable_id(variable_id) + return self.variables[variable_id].upper_bound + + def get_variable_is_integer(self, variable_id: int) -> bool: + self._check_variable_id(variable_id) + return self.variables[variable_id].is_integer + + def get_variable_name(self, variable_id: int) -> str: + self._check_variable_id(variable_id) + return self.variables[variable_id].name + + def get_variables(self) -> Iterator[int]: + yield from self.variables.keys() + + def add_linear_constraint(self, lb: float, ub: float, name: str) -> int: + lin_con_id = self._next_lin_con_id + self._next_lin_con_id += 1 + self.linear_constraints[lin_con_id] = _LinearConstraintStorage(lb, ub, name) + return lin_con_id + + def delete_linear_constraint(self, linear_constraint_id: int) -> None: + self._check_linear_constraint_id(linear_constraint_id) + con = self.linear_constraints[linear_constraint_id] + # First update the watchers + for watcher in self._update_trackers: + if linear_constraint_id < watcher.linear_constraints_checkpoint: + watcher.linear_constraint_deletes.add(linear_constraint_id) + watcher.linear_constraint_lbs.discard(linear_constraint_id) + watcher.linear_constraint_ubs.discard(linear_constraint_id) + for var_id in con.variable_nonzeros: + if var_id < watcher.variables_checkpoint: + watcher.linear_constraint_matrix.discard( + (linear_constraint_id, var_id) + ) + # Then update self. + for var_id in con.variable_nonzeros: + self.variables[var_id].linear_constraint_nonzeros.remove( + linear_constraint_id + ) + del self._linear_constraint_matrix[(linear_constraint_id, var_id)] + del self.linear_constraints[linear_constraint_id] + + def linear_constraint_exists(self, linear_constraint_id: int) -> bool: + return linear_constraint_id in self.linear_constraints + + def next_linear_constraint_id(self) -> int: + return self._next_lin_con_id + + def set_linear_constraint_lb(self, linear_constraint_id: int, lb: float) -> None: + self._check_linear_constraint_id(linear_constraint_id) + if lb == self.linear_constraints[linear_constraint_id].lower_bound: + return + self.linear_constraints[linear_constraint_id].lower_bound = lb + for watcher in self._update_trackers: + if linear_constraint_id < watcher.linear_constraints_checkpoint: + watcher.linear_constraint_lbs.add(linear_constraint_id) + + def set_linear_constraint_ub(self, linear_constraint_id: int, ub: float) -> None: + self._check_linear_constraint_id(linear_constraint_id) + if ub == self.linear_constraints[linear_constraint_id].upper_bound: + return + self.linear_constraints[linear_constraint_id].upper_bound = ub + for watcher in self._update_trackers: + if linear_constraint_id < watcher.linear_constraints_checkpoint: + watcher.linear_constraint_ubs.add(linear_constraint_id) + + def get_linear_constraint_lb(self, linear_constraint_id: int) -> float: + self._check_linear_constraint_id(linear_constraint_id) + return self.linear_constraints[linear_constraint_id].lower_bound + + def get_linear_constraint_ub(self, linear_constraint_id: int) -> float: + self._check_linear_constraint_id(linear_constraint_id) + return self.linear_constraints[linear_constraint_id].upper_bound + + def get_linear_constraint_name(self, linear_constraint_id: int) -> str: + self._check_linear_constraint_id(linear_constraint_id) + return self.linear_constraints[linear_constraint_id].name + + def get_linear_constraints(self) -> Iterator[int]: + yield from self.linear_constraints.keys() + + def set_linear_constraint_coefficient( + self, linear_constraint_id: int, variable_id: int, value: float + ) -> None: + self._check_linear_constraint_id(linear_constraint_id) + self._check_variable_id(variable_id) + if value == self._linear_constraint_matrix.get( + (linear_constraint_id, variable_id), 0.0 + ): + return + if value == 0.0: + self._linear_constraint_matrix.pop( + (linear_constraint_id, variable_id), None + ) + self.variables[variable_id].linear_constraint_nonzeros.discard( + linear_constraint_id + ) + self.linear_constraints[linear_constraint_id].variable_nonzeros.discard( + variable_id + ) + else: + self._linear_constraint_matrix[(linear_constraint_id, variable_id)] = value + self.variables[variable_id].linear_constraint_nonzeros.add( + linear_constraint_id + ) + self.linear_constraints[linear_constraint_id].variable_nonzeros.add( + variable_id + ) + for watcher in self._update_trackers: + if ( + variable_id < watcher.variables_checkpoint + and linear_constraint_id < watcher.linear_constraints_checkpoint + ): + watcher.linear_constraint_matrix.add( + (linear_constraint_id, variable_id) + ) + + def get_linear_constraint_coefficient( + self, linear_constraint_id: int, variable_id: int + ) -> float: + self._check_linear_constraint_id(linear_constraint_id) + self._check_variable_id(variable_id) + return self._linear_constraint_matrix.get( + (linear_constraint_id, variable_id), 0.0 + ) + + def get_linear_constraints_with_variable(self, variable_id: int) -> Iterator[int]: + self._check_variable_id(variable_id) + yield from self.variables[variable_id].linear_constraint_nonzeros + + def get_variables_for_linear_constraint( + self, linear_constraint_id: int + ) -> Iterator[int]: + self._check_linear_constraint_id(linear_constraint_id) + yield from self.linear_constraints[linear_constraint_id].variable_nonzeros + + def get_linear_constraint_matrix_entries( + self, + ) -> Iterator[model_storage.LinearConstraintMatrixIdEntry]: + for (constraint, variable), coef in self._linear_constraint_matrix.items(): + yield model_storage.LinearConstraintMatrixIdEntry( + linear_constraint_id=constraint, + variable_id=variable, + coefficient=coef, + ) + + def clear_objective(self) -> None: + for variable_id in self.linear_objective_coefficient: + for watcher in self._update_trackers: + if variable_id < watcher.variables_checkpoint: + watcher.linear_objective_coefficients.add(variable_id) + self.linear_objective_coefficient.clear() + for key in self._quadratic_objective_coefficients.keys(): + for watcher in self._update_trackers: + if key.id2 < watcher.variables_checkpoint: + watcher.quadratic_objective_coefficients.add(key) + self._quadratic_objective_coefficients.clear() + self.set_objective_offset(0.0) + + def set_linear_objective_coefficient(self, variable_id: int, value: float) -> None: + self._check_variable_id(variable_id) + if value == self.linear_objective_coefficient.get(variable_id, 0.0): + return + if value == 0.0: + self.linear_objective_coefficient.pop(variable_id, None) + else: + self.linear_objective_coefficient[variable_id] = value + for watcher in self._update_trackers: + if variable_id < watcher.variables_checkpoint: + watcher.linear_objective_coefficients.add(variable_id) + + def get_linear_objective_coefficient(self, variable_id: int) -> float: + self._check_variable_id(variable_id) + return self.linear_objective_coefficient.get(variable_id, 0.0) + + def get_linear_objective_coefficients( + self, + ) -> Iterator[model_storage.LinearObjectiveEntry]: + for var_id, coef in self.linear_objective_coefficient.items(): + yield model_storage.LinearObjectiveEntry( + variable_id=var_id, coefficient=coef + ) + + def set_quadratic_objective_coefficient( + self, first_variable_id: int, second_variable_id: int, value: float + ) -> None: + self._check_variable_id(first_variable_id) + self._check_variable_id(second_variable_id) + updated = self._quadratic_objective_coefficients.set_coefficient( + first_variable_id, second_variable_id, value + ) + if updated: + for watcher in self._update_trackers: + if ( + max(first_variable_id, second_variable_id) + < watcher.variables_checkpoint + ): + watcher.quadratic_objective_coefficients.add( + _QuadraticKey(first_variable_id, second_variable_id) + ) + + def get_quadratic_objective_coefficient( + self, first_variable_id: int, second_variable_id: int + ) -> float: + self._check_variable_id(first_variable_id) + self._check_variable_id(second_variable_id) + return self._quadratic_objective_coefficients.get_coefficient( + first_variable_id, second_variable_id + ) + + def get_quadratic_objective_coefficients( + self, + ) -> Iterator[model_storage.QuadraticEntry]: + yield from self._quadratic_objective_coefficients.coefficients() + + def get_quadratic_objective_adjacent_variables( + self, variable_id: int + ) -> Iterator[int]: + self._check_variable_id(variable_id) + yield from self._quadratic_objective_coefficients.get_adjacent_variables( + variable_id + ) + + def set_is_maximize(self, is_maximize: bool) -> None: + if self._is_maximize == is_maximize: + return + self._is_maximize = is_maximize + for watcher in self._update_trackers: + watcher.objective_direction = True + + def get_is_maximize(self) -> bool: + return self._is_maximize + + def set_objective_offset(self, offset: float) -> None: + if self._objective_offset == offset: + return + self._objective_offset = offset + for watcher in self._update_trackers: + watcher.objective_offset = True + + def get_objective_offset(self) -> float: + return self._objective_offset + + def export_model(self) -> model_pb2.ModelProto: + m: model_pb2.ModelProto = model_pb2.ModelProto() + m.name = self._name + _variables_to_proto(self.variables.items(), m.variables) + _linear_constraints_to_proto( + self.linear_constraints.items(), m.linear_constraints + ) + m.objective.maximize = self._is_maximize + m.objective.offset = self._objective_offset + if self.linear_objective_coefficient: + obj_ids, obj_coefs = zip(*sorted(self.linear_objective_coefficient.items())) + m.objective.linear_coefficients.ids.extend(obj_ids) + m.objective.linear_coefficients.values.extend(obj_coefs) + if self._quadratic_objective_coefficients: + first_var_ids, second_var_ids, coefficients = zip( + *sorted( + [ + (entry.id_key.id1, entry.id_key.id2, entry.coefficient) + for entry in self._quadratic_objective_coefficients.coefficients() + ] + ) + ) + m.objective.quadratic_coefficients.row_ids.extend(first_var_ids) + m.objective.quadratic_coefficients.column_ids.extend(second_var_ids) + m.objective.quadratic_coefficients.coefficients.extend(coefficients) + if self._linear_constraint_matrix: + flat_matrix_items = [ + (con_id, var_id, coef) + for ((con_id, var_id), coef) in self._linear_constraint_matrix.items() + ] + lin_con_ids, var_ids, lin_con_coefs = zip(*sorted(flat_matrix_items)) + m.linear_constraint_matrix.row_ids.extend(lin_con_ids) + m.linear_constraint_matrix.column_ids.extend(var_ids) + m.linear_constraint_matrix.coefficients.extend(lin_con_coefs) + return m + + def add_update_tracker(self) -> model_storage.StorageUpdateTracker: + tracker = _UpdateTracker(self) + self._update_trackers.add(tracker) + return tracker + + def remove_update_tracker( + self, tracker: model_storage.StorageUpdateTracker + ) -> None: + self._update_trackers.remove(tracker) + tracker.retired = True + + def _check_variable_id(self, variable_id: int) -> None: + if variable_id not in self.variables: + raise model_storage.BadVariableIdError(variable_id) + + def _check_linear_constraint_id(self, linear_constraint_id: int) -> None: + if linear_constraint_id not in self.linear_constraints: + raise model_storage.BadLinearConstraintIdError(linear_constraint_id) + + +def _set_sparse_double_vector( + id_value_pairs: Iterable[Tuple[int, float]], + proto: sparse_containers_pb2.SparseDoubleVectorProto, +) -> None: + """id_value_pairs must be sorted, proto is filled.""" + if not id_value_pairs: + return + ids, values = zip(*id_value_pairs) + proto.ids[:] = ids + proto.values[:] = values + + +def _set_sparse_bool_vector( + id_value_pairs: Iterable[Tuple[int, bool]], + proto: sparse_containers_pb2.SparseBoolVectorProto, +) -> None: + """id_value_pairs must be sorted, proto is filled.""" + if not id_value_pairs: + return + ids, values = zip(*id_value_pairs) + proto.ids[:] = ids + proto.values[:] = values + + +def _variables_to_proto( + variables: Iterable[Tuple[int, _VariableStorage]], + proto: model_pb2.VariablesProto, +) -> None: + """Exports variables to proto.""" + has_named_var = False + for _, var_storage in variables: + if var_storage.name: + has_named_var = True + break + for var_id, var_storage in variables: + proto.ids.append(var_id) + proto.lower_bounds.append(var_storage.lower_bound) + proto.upper_bounds.append(var_storage.upper_bound) + proto.integers.append(var_storage.is_integer) + if has_named_var: + proto.names.append(var_storage.name) + + +def _linear_constraints_to_proto( + linear_constraints: Iterable[Tuple[int, _LinearConstraintStorage]], + proto: model_pb2.LinearConstraintsProto, +) -> None: + """Exports variables to proto.""" + has_named_lin_con = False + for _, lin_con_storage in linear_constraints: + if lin_con_storage.name: + has_named_lin_con = True + break + for lin_con_id, lin_con_storage in linear_constraints: + proto.ids.append(lin_con_id) + proto.lower_bounds.append(lin_con_storage.lower_bound) + proto.upper_bounds.append(lin_con_storage.upper_bound) + if has_named_lin_con: + proto.names.append(lin_con_storage.name) diff --git a/ortools/math_opt/python/hash_model_storage_test.py b/ortools/math_opt/python/hash_model_storage_test.py new file mode 100644 index 0000000000..f1a4b00f7d --- /dev/null +++ b/ortools/math_opt/python/hash_model_storage_test.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for hash_model_storage that cannot be covered by model_storage_(update)_test.""" + +import unittest +from ortools.math_opt.python import hash_model_storage + + +class HashModelStorageTest(unittest.TestCase): + def test_quadratic_term_storage(self): + storage = hash_model_storage._QuadraticTermStorage() + storage.set_coefficient(0, 1, 1.0) + storage.delete_variable(0) + self.assertEmpty(list(storage.get_adjacent_variables(0))) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/linear_expression_test.py b/ortools/math_opt/python/linear_expression_test.py new file mode 100644 index 0000000000..bf55503435 --- /dev/null +++ b/ortools/math_opt/python/linear_expression_test.py @@ -0,0 +1,2797 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math +from typing import Any, List, NamedTuple, Union +from unittest import mock + +import unittest +from google3.testing.pybase import parameterized +from ortools.math_opt.python import model + +_LINEAR_TYPES = ( + "Variable", + "LinearTerm", + "LinearExpression", + "LinearSum", + "LinearProduct", +) +_QUADRATIC_TYPES = ( + "QuadraticTerm", + "QuadraticExpression", + "QuadraticSum", + "LinearLinearProduct", + "QuadraticProduct", +) + + +class BoundedExprTest(unittest.TestCase): + def test_eq_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = x + 2 * y + 1.0 == 2.0 + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0}) + self.assertEqual(bounded_expr.lower_bound, 2.0) + self.assertEqual(bounded_expr.upper_bound, 2.0) + + # Also call __eq__ directly to confirm there are no pytype issues. + def test_eq_float_explicit(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = (x + 2 * y + 1.0).__eq__(2.0) + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0}) + self.assertEqual(bounded_expr.lower_bound, 2.0) + self.assertEqual(bounded_expr.upper_bound, 2.0) + + def test_eq_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = x + 2 * y + 1.0 == 3 * y - 2.0 + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 3.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: -1.0}) + self.assertEqual(bounded_expr.lower_bound, 0.0) + self.assertEqual(bounded_expr.upper_bound, 0.0) + + # Check Variable.__eq__ calls LinearBase.__eq__ when appropriate. + bounded_expr_var_on_lhs = x == 3 * y - 2.0 + self.assertIsInstance(bounded_expr_var_on_lhs, model.BoundedLinearExpression) + flat_expr_var_on_lhs = model.as_flat_linear_expression( + bounded_expr_var_on_lhs.expression + ) + self.assertEqual(flat_expr_var_on_lhs.offset, 2.0) + self.assertDictEqual(dict(flat_expr_var_on_lhs.terms), {x: 1.0, y: -3.0}) + self.assertEqual(bounded_expr_var_on_lhs.lower_bound, 0.0) + self.assertEqual(bounded_expr_var_on_lhs.upper_bound, 0.0) + + bounded_expr_var_on_rhs = 3 * y - 2.0 == x + self.assertIsInstance(bounded_expr_var_on_rhs, model.BoundedLinearExpression) + flat_expr_var_on_rhs = model.as_flat_linear_expression( + bounded_expr_var_on_rhs.expression + ) + self.assertEqual(flat_expr_var_on_rhs.offset, -2.0) + self.assertDictEqual(dict(flat_expr_var_on_rhs.terms), {x: -1.0, y: 3.0}) + self.assertEqual(bounded_expr_var_on_rhs.lower_bound, 0.0) + self.assertEqual(bounded_expr_var_on_rhs.upper_bound, 0.0) + + # Also call __eq__ directly to confirm there are no pytype issues. + def test_eq_expr_explicit(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = (x + 2 * y + 1.0).__eq__(3 * y - 2.0) + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 3.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: -1.0}) + self.assertEqual(bounded_expr.lower_bound, 0.0) + self.assertEqual(bounded_expr.upper_bound, 0.0) + + # Check Variable.__eq__ calls LinearBase.__eq__ when appropriate. + bounded_expr_var_on_lhs = x.__eq__(3 * y - 2.0) + self.assertIsInstance(bounded_expr_var_on_lhs, model.BoundedLinearExpression) + flat_expr_var_on_lhs = model.as_flat_linear_expression( + bounded_expr_var_on_lhs.expression + ) + self.assertEqual(flat_expr_var_on_lhs.offset, 2.0) + self.assertDictEqual(dict(flat_expr_var_on_lhs.terms), {x: 1.0, y: -3.0}) + self.assertEqual(bounded_expr_var_on_lhs.lower_bound, 0.0) + self.assertEqual(bounded_expr_var_on_lhs.upper_bound, 0.0) + + bounded_expr_var_on_rhs = (3 * y - 2.0).__eq__(x) + self.assertIsInstance(bounded_expr_var_on_rhs, model.BoundedLinearExpression) + flat_expr_var_on_rhs = model.as_flat_linear_expression( + bounded_expr_var_on_rhs.expression + ) + self.assertEqual(flat_expr_var_on_rhs.offset, -2.0) + self.assertDictEqual(dict(flat_expr_var_on_rhs.terms), {x: -1.0, y: 3.0}) + self.assertEqual(bounded_expr_var_on_rhs.lower_bound, 0.0) + self.assertEqual(bounded_expr_var_on_rhs.upper_bound, 0.0) + + def test_var_eq_var(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + also_x = x + bounded_expr = x == y + self.assertIsInstance(bounded_expr, model.VarEqVar) + self.assertEqual(bounded_expr.first_variable, x) + self.assertEqual(bounded_expr.second_variable, y) + + second_mod = model.Model() + second_x = second_mod.add_binary_variable(name="x") + # pylint: disable=g-generic-assert + self.assertTrue(x == also_x) + self.assertFalse(x == y) + self.assertEqual(x.id, second_x.id) + self.assertFalse(x == second_x) + # pylint: enable=g-generic-assert + + # Also call __eq__ directly to confirm there are no pytype issues (see + # b/227214976). + def test_var_eq_var_explicit(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + also_x = x + bounded_expr = x.__eq__(y) + self.assertIsInstance(bounded_expr, model.VarEqVar) + self.assertEqual(bounded_expr.first_variable, x) + self.assertEqual(bounded_expr.second_variable, y) + + second_mod = model.Model() + second_x = second_mod.add_binary_variable(name="x") + # pylint: disable=g-generic-assert + self.assertTrue(x.__eq__(also_x)) + self.assertFalse(x.__eq__(y)) + self.assertEqual(x.id, second_x.id) + self.assertFalse(x.__eq__(second_x)) + # pylint: enable=g-generic-assert + + def test_var_neq_var(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + also_x = x + second_mod = model.Model() + second_x = second_mod.add_binary_variable(name="x") + # pylint: disable=g-generic-assert + self.assertFalse(x != also_x) + self.assertTrue(x != y) + self.assertEqual(x.id, second_x.id) + self.assertTrue(x != second_x) + # pylint: enable=g-generic-assert + + # Also call __ne__ directly to confirm there are no pytype issues (see + # b/227214976). + def test_var_neq_var_explicit(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + also_x = x + second_mod = model.Model() + second_x = second_mod.add_binary_variable(name="x") + # pylint: disable=g-generic-assert + self.assertFalse(x.__ne__(also_x)) + self.assertTrue(x.__ne__(y)) + self.assertEqual(x.id, second_x.id) + self.assertTrue(x.__ne__(second_x)) + # pylint: enable=g-generic-assert + + # Mock Variable.__hash__ to have a collision in the dictionary lookup so that + # a correct behavior of x == y is needed to recover the values. For instance, + # if VarEqVar.__bool__ always returned True, this test would fail. + @mock.patch.object(model.Variable, "__hash__") + def test_var_dict(self, fixed_hash: mock.MagicMock) -> None: + fixed_hash.return_value = 111 + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + var_dict = {x: 1.0, y: 2.0} + self.assertEqual(x.__hash__(), 111) + self.assertEqual(y.__hash__(), 111) + self.assertEqual(var_dict[x], 1.0) + self.assertEqual(var_dict[y], 2.0) + + def test_leq_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = x + 2 * y + 1.0 <= 2.0 + self.assertIsInstance(bounded_expr, model.UpperBoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0}) + self.assertEqual(bounded_expr.upper_bound, 2.0) + + def test_leq_float_rev(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = 2.0 >= x + 2 * y + 1.0 + self.assertIsInstance(bounded_expr, model.UpperBoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0}) + self.assertEqual(bounded_expr.upper_bound, 2.0) + + def test_geq_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = x + 2 * y + 1.0 >= 2.0 + self.assertIsInstance(bounded_expr, model.LowerBoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0}) + self.assertEqual(bounded_expr.lower_bound, 2.0) + + def test_geq_float_rev(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = 2.0 <= x + 2 * y + 1.0 + self.assertIsInstance(bounded_expr, model.LowerBoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0}) + self.assertEqual(bounded_expr.lower_bound, 2.0) + + def test_geq_leq_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = (0.0 <= x + 2 * y + 1.0) <= 2.0 + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0}) + self.assertEqual(bounded_expr.upper_bound, 2.0) + self.assertEqual(bounded_expr.lower_bound, 0.0) + + def test_geq_leq_float_rev(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = 2.0 >= (x + 2 * y + 1.0 >= 0) + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0}) + self.assertEqual(bounded_expr.upper_bound, 2.0) + self.assertEqual(bounded_expr.lower_bound, 0.0) + + def test_leq_geq_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = 0.0 <= (x + 2 * y + 1.0 <= 2.0) + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0}) + self.assertEqual(bounded_expr.upper_bound, 2.0) + self.assertEqual(bounded_expr.lower_bound, 0.0) + + def test_leq_geq_float_rev(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = (2.0 >= x + 2 * y + 1.0) >= 0 + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0}) + self.assertEqual(bounded_expr.upper_bound, 2.0) + self.assertEqual(bounded_expr.lower_bound, 0.0) + + def test_leq_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + bounded_expr = x + 3 * y + 2.0 <= y - 4.0 * z + 1.0 + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0, z: 4.0}) + self.assertEqual(bounded_expr.lower_bound, -math.inf) + self.assertEqual(bounded_expr.upper_bound, 0.0) + + def test_geq_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + bounded_expr = x + 3 * y + 2.0 >= y - 4.0 * z + 1.0 + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + flat_expr = model.as_flat_linear_expression(bounded_expr.expression) + self.assertEqual(flat_expr.offset, 1.0) + self.assertDictEqual(dict(flat_expr.terms), {x: 1.0, y: 2.0, z: 4.0}) + self.assertEqual(bounded_expr.lower_bound, 0.0) + self.assertEqual(bounded_expr.upper_bound, math.inf) + + +class BoundedExprErrorTest(unittest.TestCase): + def test_ne(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + # pylint: disable=pointless-statement + with self.assertRaisesWithLiteralMatch( + TypeError, "!= constraints are not supported" + ): + x != y - x + with self.assertRaisesWithLiteralMatch( + TypeError, "!= constraints are not supported" + ): + x.__ne__(y - x) + with self.assertRaisesWithLiteralMatch( + TypeError, "!= constraints are not supported" + ): + y - x != x + with self.assertRaisesWithLiteralMatch( + TypeError, "!= constraints are not supported" + ): + (y - x).__ne__(x) + with self.assertRaisesWithLiteralMatch( + TypeError, "!= constraints are not supported" + ): + y - x != x + y + with self.assertRaisesWithLiteralMatch( + TypeError, "!= constraints are not supported" + ): + (y - x).__ne__(x + y) + # pylint: enable=pointless-statement + + def test_eq(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + # pylint: disable=pointless-statement + with self.assertRaisesWithLiteralMatch( + TypeError, "unsupported operand type(s) for ==: 'Variable' and 'str'" + ): + x == "x" # pylint: disable=pointless-statement + + # pylint: disable=pointless-statement + # pytype: disable=unsupported-operands + with self.assertRaisesWithLiteralMatch( + TypeError, "unsupported operand type(s) for ==: 'Variable' and 'str'" + ): + x.__eq__("x") + # pylint: enable=pointless-statement + # pylint: enable=unsupported-operands + + def test_float_le_expr_le_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + self.assertIsInstance( + 0.0 <= x + 2 * y + 1.0, model.LowerBoundedLinearExpression + ) + with self.assertRaisesRegex( + TypeError, + "__bool__ is unsupported.*\n.*two-sided or ranged linear inequality", + ): + (0.0 <= x + 2 * y + 1.0 <= 2.0) # pylint: disable=pointless-statement + + def test_float_ge_expr_ge_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + self.assertIsInstance( + 2.0 >= x + 2 * y + 1.0, model.UpperBoundedLinearExpression + ) + with self.assertRaisesRegex( + TypeError, + "__bool__ is unsupported.*\n.*two-sided or ranged linear inequality", + ): + (2.0 >= x + 2 * y + 1.0 >= 0.0) # pylint: disable=pointless-statement + + def test_expr_le_expr_le_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + self.assertIsInstance(x <= x + 2 * y + 1.0, model.BoundedLinearExpression) + with self.assertRaisesRegex( + TypeError, + "__bool__ is unsupported.*\n.*two-sided or ranged linear inequality.*", + ): + (x <= x + 2 * y + 1.0 <= 2.0) # pylint: disable=pointless-statement + + def test_expr_ge_expr_ge_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + self.assertIsInstance(x >= x + 2 * y + 1.0, model.BoundedLinearExpression) + with self.assertRaisesRegex( + TypeError, + "__bool__ is unsupported.*\n.*two-sided or ranged linear inequality", + ): + (x >= x + 2 * y + 1.0 >= 0.0) # pylint: disable=pointless-statement + + def test_lower_bounded_expr_leq_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + rhs_expr = 2.0 + x + lower_bounded_expr = 0.0 <= x + 2 * y + 1.0 + self.assertIsInstance(lower_bounded_expr, model.LowerBoundedLinearExpression) + # pylint: disable=pointless-statement + with self.assertRaisesWithLiteralMatch( + TypeError, + "unsupported operand type(s) for <=:" + f" {type(lower_bounded_expr).__name__!r} and" + f" {type(rhs_expr).__name__!r}", + ): + lower_bounded_expr <= rhs_expr + # pylint: enable=pointless-statement + + def test_lower_bounded_expr_geq_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + rhs_expr = 2.0 + x + lower_bounded_expr = 0.0 <= x + 2 * y + 1.0 + self.assertIsInstance(lower_bounded_expr, model.LowerBoundedLinearExpression) + # pylint: disable=pointless-statement + with self.assertRaisesWithLiteralMatch( + TypeError, + f"unsupported operand type(s) for <=: {type(rhs_expr).__name__!r} and" + f" {type(lower_bounded_expr).__name__!r}", + ): + lower_bounded_expr >= rhs_expr + # pylint: enable=pointless-statement + + def test_lower_bounded_expr_geq_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + lower_bounded_expr = 0.0 <= x + 2 * y + 1.0 + self.assertIsInstance(lower_bounded_expr, model.LowerBoundedLinearExpression) + # pylint: disable=pointless-statement + with self.assertRaisesWithLiteralMatch( + TypeError, + "'>=' not supported between instances of" + f" {type(lower_bounded_expr).__name__!r} and 'float'", + ): + lower_bounded_expr >= 2.0 + # pylint: enable=pointless-statement + + def test_upper_bounded_expr_geq_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + rhs_expr = 1.0 + x + upper_bounded_expr = 2.0 >= x + 2 * y + 1.0 + self.assertIsInstance(upper_bounded_expr, model.UpperBoundedLinearExpression) + # pylint: disable=pointless-statement + with self.assertRaisesWithLiteralMatch( + TypeError, + "unsupported operand type(s) for >=:" + f" {type(upper_bounded_expr).__name__!r} and" + f" {type(rhs_expr).__name__!r}", + ): + upper_bounded_expr >= rhs_expr + # pylint: enable=pointless-statement + + def test_upper_bounded_expr_leq_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + rhs_expr = 1.0 + x + upper_bounded_expr = 2.0 >= x + 2 * y + 1.0 + self.assertIsInstance(upper_bounded_expr, model.UpperBoundedLinearExpression) + # pylint: disable=pointless-statement + with self.assertRaisesWithLiteralMatch( + TypeError, + f"unsupported operand type(s) for >=: {type(rhs_expr).__name__!r} and" + f" {type(upper_bounded_expr).__name__!r}", + ): + upper_bounded_expr <= rhs_expr + # pylint: enable=pointless-statement + + def test_upper_bounded_expr_leq_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + upper_bounded_expr = 2.0 >= x + 2 * y + 1.0 + self.assertIsInstance(upper_bounded_expr, model.UpperBoundedLinearExpression) + # pylint: disable=pointless-statement + with self.assertRaisesWithLiteralMatch( + TypeError, + "'<=' not supported between instances of" + f" {type(upper_bounded_expr).__name__!r} and 'float'", + ): + upper_bounded_expr <= 2.0 + # pylint: enable=pointless-statement + + def test_bounded_expr_leq_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + rhs_expr = 1.0 + x + bounded_expr = (0.0 <= x + 2 * y + 1.0) <= 2.0 + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + # pylint: disable=pointless-statement + with self.assertRaisesRegex( + TypeError, + "unsupported operand.*\n.*two or more non-constant linear expressions", + ): + bounded_expr <= rhs_expr + # pylint: enable=pointless-statement + + def test_bounded_expr_leq_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = (0.0 <= x + 2 * y + 1.0) <= 2.0 + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + # pylint: disable=pointless-statement + with self.assertRaisesWithLiteralMatch( + TypeError, + "'<=' not supported between instances of" + f" {type(bounded_expr).__name__!r} and 'float'", + ): + bounded_expr <= 2.0 + # pylint: enable=pointless-statement + + def test_bounded_expr_geq_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + rhs_expr = 1.0 + x + bounded_expr = (0.0 <= x + 2 * y + 1.0) <= 2.0 + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + # pylint: disable=pointless-statement + with self.assertRaisesRegex( + TypeError, + "unsupported operand.*\n.*two or more non-constant linear expressions", + ): + bounded_expr >= rhs_expr + # pylint: enable=pointless-statement + + def test_bounded_expr_geq_float(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = (0.0 <= x + 2 * y + 1.0) <= 2.0 + self.assertIsInstance(bounded_expr, model.BoundedLinearExpression) + # pylint: disable=pointless-statement + with self.assertRaisesWithLiteralMatch( + TypeError, + "'>=' not supported between instances of" + f" {type(bounded_expr).__name__!r} and 'float'", + ): + bounded_expr >= 2.0 + # pylint: enable=pointless-statement + + +class BoundedExprStrAndReprTest(unittest.TestCase): + def test_upper_bounded_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = x + 2 * y + 1.0 <= 4.0 + self.assertEqual( + repr(bounded_expr), + f'LinearSum((LinearSum((,' + f' LinearTerm(, 2))), 1.0)) <= 4.0', + ) + self.assertEqual(str(bounded_expr), "1.0 + 1.0 * x + 2.0 * y <= 4.0") + + def test_lower_bounded_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = x + 2 * y + 1.0 >= 2.0 + self.assertEqual( + repr(bounded_expr), + f'LinearSum((LinearSum((,' + f' LinearTerm(, 2))), 1.0)) >= 2.0', + ) + self.assertEqual(str(bounded_expr), "1.0 + 1.0 * x + 2.0 * y >= 2.0") + + def test_bounded_expr(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + bounded_expr = (2.0 <= x + 2 * y + 1.0) <= 4.0 + self.assertEqual( + repr(bounded_expr), + f'2.0 <= LinearSum((LinearSum((,' + f' LinearTerm(, 2))), 1.0)) <= 4.0', + ) + self.assertEqual(str(bounded_expr), "2.0 <= 1.0 + 1.0 * x + 2.0 * y <= 4.0") + + +# TODO(b/216492143): change __str__ to match C++ implementation in cl/421649402. +class LinearStrAndReprTest(parameterized.TestCase): + def test_sorting_ok(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + zero_plus_var_plus_pos_term = 0 + x + 2 * y + self.assertEqual( + repr(zero_plus_var_plus_pos_term), + f'LinearSum((LinearSum((0, )), ' + f'LinearTerm(, 2)))', + ) + # This fails if we don't sort by variable names in Variable.__str__(). + self.assertEqual(str(zero_plus_var_plus_pos_term), "0.0 + 1.0 * x + 2.0 * y") + + def test_simple_expressions(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + + var_plus_pos_term = x + 2 * y + self.assertEqual( + repr(var_plus_pos_term), + f'LinearSum((, ' + f'LinearTerm(, 2)))', + ) + self.assertEqual(str(var_plus_pos_term), "0.0 + 1.0 * x + 2.0 * y") + + var_plus_neg_term = x - 3 * y + self.assertEqual( + repr(var_plus_neg_term), + f'LinearSum((, ' + f'LinearTerm(, -3)))', + ) + self.assertEqual(str(var_plus_neg_term), "0.0 + 1.0 * x - 3.0 * y") + + var_plus_num = x + 1.0 + self.assertEqual( + repr(var_plus_num), f'LinearSum((, 1.0))' + ) + self.assertEqual(str(var_plus_num), "1.0 + 1.0 * x") + + num_times_var_sum = 2 * (x + y + 3) + self.assertEqual( + repr(num_times_var_sum), + "LinearProduct(2.0, LinearSum((LinearSum((, )), 3)))', + ) + self.assertEqual(str(num_times_var_sum), "6.0 + 2.0 * x + 2.0 * y") + self.assertEqual( + repr(model.as_flat_linear_expression(num_times_var_sum)), + f'LinearExpression(6.0, {"{"!s}' + f': 2.0, ' + f': 2.0{"}"!s})', + ) + self.assertEqual( + str(model.as_flat_linear_expression(num_times_var_sum)), + "6.0 + 2.0 * x + 2.0 * y", + ) + + linear_term = 2 * x + self.assertEqual( + repr(linear_term), f'LinearTerm(, 2)' + ) + self.assertEqual(str(linear_term), "2 * x") + + def test_sum_expressions(self) -> None: + mod = model.Model() + x = [mod.add_binary_variable(name=f"x{i}") for i in range(3)] + + linear_sum = model.LinearSum(i * x[i] for i in range(3)) + self.assertEqual( + repr(linear_sum), + "LinearSum((" + f'LinearTerm(, 0), ' + f'LinearTerm(, 1), ' + f'LinearTerm(, 2)))', + ) + self.assertEqual(str(linear_sum), "0.0 + 1.0 * x1 + 2.0 * x2") + + python_sum = sum(i * x[i] for i in range(3)) + self.assertEqual( + repr(python_sum), + "LinearSum((" + "LinearSum((" + f'LinearSum((0, LinearTerm(, 0))), ' + f'LinearTerm(, 1))), ' + f'LinearTerm(, 2)))', + ) + self.assertEqual(str(python_sum), "0.0 + 1.0 * x1 + 2.0 * x2") + + +# TODO(b/216492143): change __str__ to match C++ implementation in cl/421649402. +class QuadraticStrAndReprTest(parameterized.TestCase): + def test_sorting_ok(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + zero_plus_var_plus_pos_term = 0 + x + 2 * y + x * x + self.assertEqual( + repr(zero_plus_var_plus_pos_term), + "QuadraticSum((" + f'LinearSum((LinearSum((0, )), ' + f'LinearTerm(, 2))), ' + f'QuadraticTerm(QuadraticTermKey(, ' + f'), 1.0)))', + ) + # This fails if we don't sort by variable names in Variable.__str__(). + self.assertEqual( + str(zero_plus_var_plus_pos_term), + "0.0 + 1.0 * x + 2.0 * y + 1.0 * x * x", + ) + + def test_simple_expressions(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + + var_plus_pos_term = x + 2 * y * y + self.assertEqual( + repr(var_plus_pos_term), + f'QuadraticSum((, ' + f'QuadraticTerm(QuadraticTermKey(, ' + f'), 2)))', + ) + self.assertEqual(str(var_plus_pos_term), "0.0 + 1.0 * x + 2.0 * y * y") + + var_plus_neg_term = x - 3 * y * y + self.assertEqual( + repr(var_plus_neg_term), + f'QuadraticSum((, ' + f'QuadraticTerm(QuadraticTermKey(, ' + f'), -3)))', + ) + self.assertEqual(str(var_plus_neg_term), "0.0 + 1.0 * x - 3.0 * y * y") + + num_times_term_sum = 2 * (x * x + y + 3) + self.assertEqual( + repr(num_times_term_sum), + "QuadraticProduct(2.0," + " QuadraticSum((QuadraticSum((QuadraticTerm(QuadraticTermKey(, ), 1.0),' + f' )), 3)))', + ) + self.assertEqual(str(num_times_term_sum), "6.0 + 2.0 * y + 2.0 * x * x") + self.assertEqual( + repr(model.as_flat_quadratic_expression(num_times_term_sum)), + f'QuadraticExpression(6.0, {"{"!s}' + f': 2.0{"}"!s}, ' + f'{"{"!s}QuadraticTermKey(, ' + f'): 2.0{"}"!s})', + ) + self.assertEqual( + str(model.as_flat_quadratic_expression(num_times_term_sum)), + "6.0 + 2.0 * y + 2.0 * x * x", + ) + + linear_times_linear = (2 * x) * (1 + y) + self.assertEqual( + repr(linear_times_linear), + f'LinearLinearProduct(LinearTerm(, 2), ' + f'LinearSum((1, )))', + ) + self.assertEqual(str(linear_times_linear), "0.0 + 2.0 * x + 2.0 * x * y") + + quadratic_term = 2 * x * x + self.assertEqual( + repr(quadratic_term), + "QuadraticTerm(QuadraticTermKey(, ), 2)', + ) + self.assertEqual(str(quadratic_term), "2 * x * x") + + def test_sum_expressions(self) -> None: + mod = model.Model() + x = [mod.add_binary_variable(name=f"x{i}") for i in range(3)] + + quadratic_sum = model.QuadraticSum(i * x[i] * x[i] for i in range(3)) + self.assertEqual( + repr(quadratic_sum), + "QuadraticSum((" + f'QuadraticTerm(QuadraticTermKey(, ' + f'), 0), ' + f'QuadraticTerm(QuadraticTermKey(, ' + f'), 1), ' + f'QuadraticTerm(QuadraticTermKey(, ' + f'), 2)))', + ) + self.assertEqual(str(quadratic_sum), "0.0 + 1.0 * x1 * x1 + 2.0 * x2 * x2") + + python_sum = sum(i * x[i] * x[i] for i in range(3)) + self.assertEqual( + repr(python_sum), + "QuadraticSum((" + "QuadraticSum((" + "QuadraticSum((0, QuadraticTerm(QuadraticTermKey(, ), 0))), ' + f'QuadraticTerm(QuadraticTermKey(, ' + f'), 1))), ' + f'QuadraticTerm(QuadraticTermKey(, ' + f'), 2)))', + ) + self.assertEqual(str(python_sum), "0.0 + 1.0 * x1 * x1 + 2.0 * x2 * x2") + + +class LinearNumberOpTestsParameters(NamedTuple): + linear_type: str + constant: Union[float, int] + linear_first: bool + + def test_suffix(self): + if self.linear_first: + return f"_{self.linear_type}_{type(self.constant).__name__}" + else: + return f"_{type(self.constant).__name__}_{self.linear_type}" + + +def all_linear_number_op_parameters() -> List[LinearNumberOpTestsParameters]: + result = [] + for t in _LINEAR_TYPES: + for c in (2, 0.25): + for first in (True, False): + result.append( + LinearNumberOpTestsParameters( + linear_type=t, constant=c, linear_first=first + ) + ) + return result + + +# Test all operations (including inplace) between a number and a Linear object +class LinearNumberOpTests(parameterized.TestCase): + @parameterized.named_parameters( + (p.test_suffix(), p.linear_type, p.constant, p.linear_first) + for p in all_linear_number_op_parameters() + ) + def test_mult( + self, linear_type: str, constant: Union[float, int], linear_first: bool + ) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + if linear_type == "Variable": + linear = x + expected_type = model.LinearTerm + expected_offset = 0 + expected_terms = {x: constant} + elif linear_type == "LinearTerm": + linear = model.LinearTerm(x, 2) + expected_type = model.LinearTerm + expected_offset = 0 + expected_terms = {x: 2 * constant} + elif linear_type == "LinearExpression": + linear = model.LinearExpression(x - 2 * y + 3) + expected_type = model.LinearProduct + expected_offset = 3 * constant + expected_terms = {x: constant, y: -2 * constant} + elif linear_type == "LinearSum": + linear = model.LinearSum((x, -2 * y, 3)) + expected_type = model.LinearProduct + expected_offset = 3 * constant + expected_terms = {x: constant, y: -2 * constant} + elif linear_type == "LinearProduct": + linear = model.LinearProduct(2, x) + expected_type = model.LinearProduct + expected_offset = 0 + expected_terms = {x: 2 * constant} + else: + raise AssertionError(f"unknown linear type: {linear_type!r}") + + # Check __mul__ and __rmul__ + s = linear * constant if linear_first else constant * linear + e = model.as_flat_linear_expression(s) + self.assertIsInstance(s, expected_type) + self.assertEqual(e.offset, expected_offset) + self.assertDictEqual(dict(e.terms), expected_terms) + + # Also check __imul__ + if linear_first: + expr = linear + expr *= constant + else: + expr = constant + expr *= linear + e_inplace = model.as_flat_linear_expression(expr) + self.assertIsInstance(expr, expected_type) + self.assertEqual(e_inplace.offset, expected_offset) + self.assertDictEqual(dict(e_inplace.terms), expected_terms) + + @parameterized.named_parameters( + (p.test_suffix(), p.linear_type, p.constant, p.linear_first) + for p in all_linear_number_op_parameters() + ) + def test_div( + self, linear_type: str, constant: Union[float, int], linear_first: bool + ) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + if linear_type == "Variable": + linear = x + expected_type = model.LinearTerm + expected_offset = 0 + expected_terms = {x: 1 / constant} + elif linear_type == "LinearTerm": + linear = model.LinearTerm(x, 2) + expected_type = model.LinearTerm + expected_offset = 0 + expected_terms = {x: 2 / constant} + elif linear_type == "LinearExpression": + linear = model.LinearExpression(x - 2 * y + 3) + expected_type = model.LinearProduct + expected_offset = 3 / constant + expected_terms = {x: 1 / constant, y: -2 / constant} + elif linear_type == "LinearSum": + linear = model.LinearSum((x, -2 * y, 3)) + expected_type = model.LinearProduct + expected_offset = 3 / constant + expected_terms = {x: 1 / constant, y: -2 / constant} + elif linear_type == "LinearProduct": + linear = model.LinearProduct(2, x) + expected_type = model.LinearProduct + expected_offset = 0 + expected_terms = {x: 2 / constant} + else: + raise AssertionError(f"unknown linear type: {linear_type!r}") + + # Check __truediv__ + if linear_first: + s = linear / constant + e = model.as_flat_linear_expression(s) + self.assertIsInstance(s, expected_type) + self.assertEqual(e.offset, expected_offset) + self.assertDictEqual(dict(e.terms), expected_terms) + else: + with self.assertRaisesWithLiteralMatch( + TypeError, + f"unsupported operand type(s) for /: {type(constant).__name__!r} " + f"and {type(linear).__name__!r}", + ): + s = constant / linear # pytype: disable=unsupported-operands + + # Also check __itruediv__ + if linear_first: + linear /= constant + e_inplace = model.as_flat_linear_expression(linear) + self.assertIsInstance(linear, expected_type) + self.assertEqual(e_inplace.offset, expected_offset) + self.assertDictEqual(dict(e_inplace.terms), expected_terms) + else: + with self.assertRaisesWithLiteralMatch( + TypeError, + f"unsupported operand type(s) for /=: {type(constant).__name__!r} " + f"and {type(linear).__name__!r}", + ): + expr = constant + expr /= linear # pytype: disable=unsupported-operands + + @parameterized.named_parameters( + (p.test_suffix(), p.linear_type, p.constant, p.linear_first) + for p in all_linear_number_op_parameters() + ) + def test_add( + self, linear_type: str, constant: Union[float, int], linear_first: bool + ) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + if linear_type == "Variable": + linear = x + expected_offset = constant + expected_terms = {x: 1} + elif linear_type == "LinearTerm": + linear = model.LinearTerm(x, 2) + expected_offset = constant + expected_terms = {x: 2} + elif linear_type == "LinearExpression": + linear = model.LinearExpression(x - 2 * y + 1) + expected_offset = constant + 1 + expected_terms = {x: 1, y: -2} + elif linear_type == "LinearSum": + linear = model.LinearSum((x, -2 * y, 1)) + expected_offset = constant + 1 + expected_terms = {x: 1, y: -2} + elif linear_type == "LinearProduct": + linear = model.LinearProduct(2, x) + expected_offset = constant + expected_terms = {x: 2} + else: + raise AssertionError(f"unknown linear type: {linear_type!r}") + + # Check __add__ and __radd__ + s = linear + constant if linear_first else constant + linear + e = model.as_flat_linear_expression(s) + self.assertIsInstance(s, model.LinearSum) + self.assertEqual(e.offset, expected_offset) + self.assertDictEqual(dict(e.terms), expected_terms) + + # Also check __iadd__ + if linear_first: + expr = linear + expr += constant + else: + expr = constant + expr += linear + e_inplace = model.as_flat_linear_expression(expr) + self.assertIsInstance(expr, model.LinearSum) + self.assertEqual(e_inplace.offset, expected_offset) + self.assertDictEqual(dict(e_inplace.terms), expected_terms) + + @parameterized.named_parameters( + (p.test_suffix(), p.linear_type, p.constant, p.linear_first) + for p in all_linear_number_op_parameters() + ) + def test_sub( + self, linear_type: str, constant: Union[float, int], linear_first: bool + ) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + sign = 1 if linear_first else -1 + if linear_type == "Variable": + linear = x + expected_offset = -sign * constant + expected_terms = {x: sign} + elif linear_type == "LinearTerm": + linear = model.LinearTerm(x, 2) + expected_offset = -sign * constant + expected_terms = {x: sign * 2} + elif linear_type == "LinearExpression": + linear = model.LinearExpression(x - 2 * y + 3) + expected_offset = -sign * constant + 3 * sign + expected_terms = {x: sign, y: -sign * 2} + elif linear_type == "LinearSum": + linear = model.LinearSum((x, -2 * y, 3)) + expected_offset = -sign * constant + 3 * sign + expected_terms = {x: sign, y: -sign * 2} + elif linear_type == "LinearProduct": + linear = model.LinearProduct(2, x) + expected_offset = -sign * constant + expected_terms = {x: sign * 2} + else: + raise AssertionError(f"unknown linear type: {linear_type!r}") + + # Check __sub__ and __rsub__ + s = linear - constant if linear_first else constant - linear + e = model.as_flat_linear_expression(s) + self.assertIsInstance(s, model.LinearSum) + self.assertEqual(e.offset, expected_offset) + self.assertDictEqual(dict(e.terms), expected_terms) + + # Also check __isub__ + if linear_first: + linear -= constant + e_inplace = model.as_flat_linear_expression(linear) + self.assertIsInstance(linear, model.LinearSum) + self.assertEqual(e_inplace.offset, expected_offset) + self.assertDictEqual(dict(e_inplace.terms), expected_terms) + + +class QuadraticTermKey(unittest.TestCase): + # Mock QuadraticTermKey.__hash__ to have a collision in the dictionary lookup + # so that a correct behavior of term1 == term2 is needed to recover the + # values. For instance, if QuadraticTermKey.__eq__ only compared equality of + # the first variables in the keys, this test would fail. + @mock.patch.object(model.QuadraticTermKey, "__hash__") + def test_var_dict(self, fixed_hash: mock.MagicMock) -> None: + fixed_hash.return_value = 111 + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + var_dict = {xx: 1, xy: 2, yy: 3} + self.assertEqual(xx.__hash__(), 111) + self.assertEqual(xy.__hash__(), 111) + self.assertEqual(yy.__hash__(), 111) + self.assertEqual(var_dict[xx], 1) + self.assertEqual(var_dict[xy], 2) + self.assertEqual(var_dict[yy], 3) + + +class QuadraticNumberOpTestsParameters(NamedTuple): + quadratic_type: str + constant: Union[float, int] + quadratic_first: bool + + def test_suffix(self): + if self.quadratic_first: + return f"_{self.quadratic_type}_{type(self.constant).__name__}" + else: + return f"_{type(self.constant).__name__}_{self.quadratic_type}" + + +def all_quadratic_number_op_parameters() -> List[QuadraticNumberOpTestsParameters]: + result = [] + for t in _QUADRATIC_TYPES: + for c in (2, 0.25): + for first in (True, False): + result.append( + QuadraticNumberOpTestsParameters( + quadratic_type=t, constant=c, quadratic_first=first + ) + ) + return result + + +# Test all operations (including inplace) between a number and a Quadratic +# object. +@parameterized.named_parameters( + (p.test_suffix(), p.quadratic_type, p.constant, p.quadratic_first) + for p in all_quadratic_number_op_parameters() +) +class QuadraticNumberOpTests(parameterized.TestCase): + def test_mult( + self, + quadratic_type: str, + constant: Union[float, int], + quadratic_first: bool, + ) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + if quadratic_type == "QuadraticTerm": + quadratic = model.QuadraticTerm(xy, 2) + expected_type = model.QuadraticTerm + expected_offset = 0 + expected_linear_terms = {} + expected_quadratic_terms = {xy: 2 * constant} + elif quadratic_type == "QuadraticExpression": + quadratic = model.QuadraticExpression(x * x - 2 * x * y - x + 3) + expected_type = model.QuadraticProduct + expected_offset = 3 * constant + expected_linear_terms = {x: -constant} + expected_quadratic_terms = {xx: constant, xy: -2 * constant} + elif quadratic_type == "QuadraticSum": + quadratic = model.QuadraticSum((x, -2 * y, 3, y * y)) + expected_type = model.QuadraticProduct + expected_offset = 3 * constant + expected_linear_terms = {x: constant, y: -2 * constant} + expected_quadratic_terms = {yy: constant} + elif quadratic_type == "LinearLinearProduct": + quadratic = model.LinearLinearProduct(x + y + 1, x + 1) + expected_type = model.QuadraticProduct + expected_offset = constant + expected_linear_terms = {x: 2 * constant, y: constant} + expected_quadratic_terms = {xx: constant, xy: constant} + elif quadratic_type == "QuadraticProduct": + quadratic = model.QuadraticProduct(2, x * x) + expected_type = model.QuadraticProduct + expected_offset = 0.0 + expected_linear_terms = {} + expected_quadratic_terms = {xx: 2 * constant} + else: + raise AssertionError(f"unknown quaratic type: {quadratic_type!r}") + + # Check __mul__ and __rmul__ + s = quadratic * constant if quadratic_first else constant * quadratic + e = model.as_flat_quadratic_expression(s) + self.assertIsInstance(s, expected_type) + self.assertEqual(e.offset, expected_offset) + self.assertDictEqual(dict(e.linear_terms), expected_linear_terms) + self.assertDictEqual(dict(e.quadratic_terms), expected_quadratic_terms) + + # Also check __imul__ + if quadratic_first: + expr = quadratic + expr *= constant + else: + expr = constant + expr *= quadratic + e_inplace = model.as_flat_quadratic_expression(expr) + self.assertIsInstance(expr, expected_type) + self.assertEqual(e_inplace.offset, expected_offset) + self.assertDictEqual(dict(e_inplace.linear_terms), expected_linear_terms) + self.assertDictEqual(dict(e_inplace.quadratic_terms), expected_quadratic_terms) + + def test_div( + self, + quadratic_type: str, + constant: Union[float, int], + quadratic_first: bool, + ) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + if quadratic_type == "QuadraticTerm": + quadratic = model.QuadraticTerm(xy, 2) + expected_type = model.QuadraticTerm + expected_offset = 0 + expected_linear_terms = {} + expected_quadratic_terms = {xy: 2 / constant} + elif quadratic_type == "QuadraticExpression": + quadratic = model.QuadraticExpression(x * x - 2 * x * y - x + 3) + expected_type = model.QuadraticProduct + expected_offset = 3 / constant + expected_linear_terms = {x: -1 / constant} + expected_quadratic_terms = {xx: 1 / constant, xy: -2 / constant} + elif quadratic_type == "QuadraticSum": + quadratic = model.QuadraticSum((x, -2 * y, 3, y * y)) + expected_type = model.QuadraticProduct + expected_offset = 3 / constant + expected_linear_terms = {x: 1 / constant, y: -2 / constant} + expected_quadratic_terms = {yy: 1 / constant} + elif quadratic_type == "LinearLinearProduct": + quadratic = model.LinearLinearProduct(x + y + 1, x + 1) + expected_type = model.QuadraticProduct + expected_offset = 1 / constant + expected_linear_terms = {x: 2 / constant, y: 1 / constant} + expected_quadratic_terms = {xx: 1 / constant, xy: 1 / constant} + elif quadratic_type == "QuadraticProduct": + quadratic = model.QuadraticProduct(2, x * x) + expected_type = model.QuadraticProduct + expected_offset = 0.0 + expected_linear_terms = {} + expected_quadratic_terms = {xx: 2 / constant} + else: + raise AssertionError(f"unknown quaratic type: {quadratic_type!r}") + + # Check __truediv__ + if quadratic_first: + s = quadratic / constant + e = model.as_flat_quadratic_expression(s) + self.assertIsInstance(s, expected_type) + self.assertEqual(e.offset, expected_offset) + self.assertDictEqual(dict(e.linear_terms), expected_linear_terms) + self.assertDictEqual(dict(e.quadratic_terms), expected_quadratic_terms) + else: + with self.assertRaisesWithLiteralMatch( + TypeError, + f"unsupported operand type(s) for /: {type(constant).__name__!r} " + f"and {type(quadratic).__name__!r}", + ): + s = constant / quadratic # pytype: disable=unsupported-operands + + # Also check __itruediv__ + if quadratic_first: + quadratic /= constant + e_inplace = model.as_flat_quadratic_expression(quadratic) + self.assertIsInstance(quadratic, expected_type) + self.assertEqual(e_inplace.offset, expected_offset) + self.assertDictEqual(dict(e_inplace.linear_terms), expected_linear_terms) + self.assertDictEqual( + dict(e_inplace.quadratic_terms), expected_quadratic_terms + ) + else: + with self.assertRaisesWithLiteralMatch( + TypeError, + f"unsupported operand type(s) for /=: {type(constant).__name__!r} " + f"and {type(quadratic).__name__!r}", + ): + expr = constant + expr /= quadratic # pytype: disable=unsupported-operands + + def test_add( + self, + quadratic_type: str, + constant: Union[float, int], + quadratic_first: bool, + ) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + if quadratic_type == "QuadraticTerm": + quadratic = model.QuadraticTerm(xy, 2) + expected_offset = constant + expected_linear_terms = {} + expected_quadratic_terms = {xy: 2} + elif quadratic_type == "QuadraticExpression": + quadratic = model.QuadraticExpression(x * x - 2 * x * y - x + 3) + expected_offset = 3 + constant + expected_linear_terms = {x: -1} + expected_quadratic_terms = {xx: 1, xy: -2} + elif quadratic_type == "QuadraticSum": + quadratic = model.QuadraticSum((x, -2 * y, 3, y * y)) + expected_offset = 3 + constant + expected_linear_terms = {x: 1, y: -2} + expected_quadratic_terms = {yy: 1} + elif quadratic_type == "LinearLinearProduct": + quadratic = model.LinearLinearProduct(x + y + 1, x + 1) + expected_offset = 1 + constant + expected_linear_terms = {x: 2, y: 1} + expected_quadratic_terms = {xx: 1, xy: 1} + elif quadratic_type == "QuadraticProduct": + quadratic = model.QuadraticProduct(2, x * x) + expected_offset = constant + expected_linear_terms = {} + expected_quadratic_terms = {xx: 2} + else: + raise AssertionError(f"unknown quaratic type: {quadratic_type!r}") + + # Check __add__ and __radd__ + s = quadratic + constant if quadratic_first else constant + quadratic + e = model.as_flat_quadratic_expression(s) + self.assertIsInstance(s, model.QuadraticSum) + self.assertEqual(e.offset, expected_offset) + self.assertDictEqual(dict(e.linear_terms), expected_linear_terms) + self.assertDictEqual(dict(e.quadratic_terms), expected_quadratic_terms) + + # Also check __iadd__ + if quadratic_first: + expr = quadratic + expr += constant + else: + expr = constant + expr += quadratic + e_inplace = model.as_flat_quadratic_expression(expr) + self.assertIsInstance(expr, model.QuadraticSum) + self.assertEqual(e_inplace.offset, expected_offset) + self.assertDictEqual(dict(e_inplace.linear_terms), expected_linear_terms) + self.assertDictEqual(dict(e_inplace.quadratic_terms), expected_quadratic_terms) + + def test_sub( + self, + quadratic_type: str, + constant: Union[float, int], + quadratic_first: bool, + ) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + sign = 1 if quadratic_first else -1 + if quadratic_type == "QuadraticTerm": + quadratic = model.QuadraticTerm(xy, 2) + expected_offset = -sign * constant + expected_linear_terms = {} + expected_quadratic_terms = {xy: sign * 2} + elif quadratic_type == "QuadraticExpression": + quadratic = model.QuadraticExpression(x * x - 2 * x * y - x + 3) + expected_offset = sign * 3 - sign * constant + expected_linear_terms = {x: sign * (-1)} + expected_quadratic_terms = {xx: sign * 1, xy: sign * (-2)} + elif quadratic_type == "QuadraticSum": + quadratic = model.QuadraticSum((x, -2 * y, 3, y * y)) + expected_offset = sign * 3 - sign * constant + expected_linear_terms = {x: sign * 1, y: sign * (-2)} + expected_quadratic_terms = {yy: sign} + elif quadratic_type == "LinearLinearProduct": + quadratic = model.LinearLinearProduct(x + y + 1, x + 1) + expected_offset = sign * 1 - sign * constant + expected_linear_terms = {x: sign * 2, y: sign * 1} + expected_quadratic_terms = {xx: sign, xy: sign} + elif quadratic_type == "QuadraticProduct": + quadratic = model.QuadraticProduct(2, x * x) + expected_offset = -sign * constant + expected_linear_terms = {} + expected_quadratic_terms = {xx: sign * 2} + else: + raise AssertionError(f"unknown quaratic type: {quadratic_type!r}") + + # Check __sub__ and __rsub__ + s = quadratic - constant if quadratic_first else constant - quadratic + e = model.as_flat_quadratic_expression(s) + self.assertIsInstance(s, model.QuadraticSum) + self.assertEqual(e.offset, expected_offset) + self.assertDictEqual(dict(e.linear_terms), expected_linear_terms) + self.assertDictEqual(dict(e.quadratic_terms), expected_quadratic_terms) + + # Also check __isub__ + if quadratic_first: + quadratic -= constant + e_inplace = model.as_flat_quadratic_expression(quadratic) + self.assertIsInstance(quadratic, model.QuadraticSum) + self.assertEqual(e_inplace.offset, expected_offset) + self.assertDictEqual(dict(e_inplace.linear_terms), expected_linear_terms) + self.assertDictEqual( + dict(e_inplace.quadratic_terms), expected_quadratic_terms + ) + + +class LinearLinearAddSubTestParams(NamedTuple): + lhs_type: str + rhs_type: str + subtract: bool + + def test_suffix(self): + return ( + f"_{self.lhs_type}_{self.rhs_type}_" + f'{"subtract" if self.subtract else "add"}' + ) + + +def all_linear_linear_add_sub_params() -> List[LinearLinearAddSubTestParams]: + result = [] + for lhs_type in _LINEAR_TYPES: + for rhs_type in _LINEAR_TYPES: + for sub in (True, False): + result.append( + LinearLinearAddSubTestParams( + lhs_type=lhs_type, rhs_type=rhs_type, subtract=sub + ) + ) + return result + + +# Test add/sub operations (including inplace) between two Linear objects. +class LinearLinearAddSubTest(parameterized.TestCase): + @parameterized.named_parameters( + (p.test_suffix(), p.lhs_type, p.rhs_type, p.subtract) + for p in all_linear_linear_add_sub_params() + ) + def test_add_and_sub(self, lhs_type: str, rhs_type: str, subtract: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + x_coefficient = 0 + y_coefficient = 0 + expected_offset = 0 + sign = -1 if subtract else 1 + # Setup first linear term. + if lhs_type == "Variable": + first_linear = x + x_coefficient += 1 + elif lhs_type == "LinearTerm": + first_linear = model.LinearTerm(x, 2) + x_coefficient += 2 + elif lhs_type == "LinearExpression": + first_linear = model.LinearExpression(x - 2 * y + 1) + x_coefficient += 1 + y_coefficient += -2 + expected_offset += 1 + elif lhs_type == "LinearSum": + first_linear = model.LinearSum((x, -2 * y, 1)) + x_coefficient += 1 + y_coefficient += -2 + expected_offset += 1 + elif lhs_type == "LinearProduct": + first_linear = model.LinearProduct(2, x) + x_coefficient += 2 + else: + raise AssertionError(f"unknown linear type: {lhs_type!r}") + + # Setup second linear term + if rhs_type == "Variable": + second_linear = y + y_coefficient += sign * 1 + elif rhs_type == "LinearTerm": + second_linear = model.LinearTerm(y, 2) + y_coefficient += sign * 2 + elif rhs_type == "LinearExpression": + second_linear = model.LinearExpression(y - 2 * x + 1) + x_coefficient += sign * (-2) + y_coefficient += sign * 1 + expected_offset += sign * 1 + elif rhs_type == "LinearSum": + second_linear = model.LinearSum((y, -2 * x, 1)) + x_coefficient += sign * (-2) + y_coefficient += sign * 1 + expected_offset += sign * 1 + elif rhs_type == "LinearProduct": + second_linear = model.LinearProduct(2, y) + y_coefficient += sign * 2 + else: + raise AssertionError(f"unknown linear type: {rhs_type!r}") + + # Check __add__ and __sub__ + s = first_linear - second_linear if subtract else first_linear + second_linear + e = model.as_flat_linear_expression(s) + self.assertIsInstance(s, model.LinearSum) + self.assertEqual(e.offset, expected_offset) + self.assertDictEqual(dict(e.terms), {x: x_coefficient, y: y_coefficient}) + + # Also check __iadd__ and __isub__ + if subtract: + first_linear -= second_linear + else: + first_linear += second_linear + e_inplace = model.as_flat_linear_expression(first_linear) + self.assertIsInstance(first_linear, model.LinearSum) + self.assertEqual(e_inplace.offset, expected_offset) + self.assertDictEqual( + dict(e_inplace.terms), {x: x_coefficient, y: y_coefficient} + ) + + +class LinearQuadraticAddSubTestParams(NamedTuple): + lhs_type: str + rhs_type: str + subtract: bool + + def test_suffix(self): + return ( + f"_{self.lhs_type}_{self.rhs_type}_" + f'{"subtract" if self.subtract else "add"}' + ) + + +def all_linear_quadratic_add_sub_params() -> List[LinearQuadraticAddSubTestParams]: + result = [] + for lhs_type in _LINEAR_TYPES + _QUADRATIC_TYPES: + for rhs_type in _LINEAR_TYPES + _QUADRATIC_TYPES: + for sub in (True, False): + result.append( + LinearQuadraticAddSubTestParams( + lhs_type=lhs_type, rhs_type=rhs_type, subtract=sub + ) + ) + return result + + +# Test add/sub operations (including inplace) between Quadratic and Linear +# objects. Also re-checks the operations for the pure Linear check when the +# result is intereted as a QuadraticExpression. +class LinearQuadraticAddSubTest(parameterized.TestCase): + def assertDictEqualWithZeroDefault( + self, dict1: dict[Any, float], dict2: dict[Any, float] + ) -> None: + for key in dict1.keys(): + if key not in dict2: + dict2[key] = 0.0 + for key in dict2.keys(): + if key not in dict1: + dict1[key] = 0.0 + self.assertDictEqual(dict1, dict2) + + @parameterized.named_parameters( + (p.test_suffix(), p.lhs_type, p.rhs_type, p.subtract) + for p in all_linear_quadratic_add_sub_params() + ) + def test_add_and_sub(self, lhs_type: str, rhs_type: str, subtract: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + x_coefficient = 0 + y_coefficient = 0 + xx_coefficient = 0 + xy_coefficient = 0 + yy_coefficient = 0 + expected_offset = 0 + sign = -1 if subtract else 1 + # Setup first linear term. + if lhs_type == "Variable": + first_linear_or_quadratic = x + x_coefficient += 1 + elif lhs_type == "LinearTerm": + first_linear_or_quadratic = model.LinearTerm(x, 2) + x_coefficient += 2 + elif lhs_type == "LinearExpression": + first_linear_or_quadratic = model.LinearExpression(x - 2 * y + 1) + x_coefficient += 1 + y_coefficient += -2 + expected_offset += 1 + elif lhs_type == "LinearSum": + first_linear_or_quadratic = model.LinearSum((x, -2 * y, 1)) + x_coefficient += 1 + y_coefficient += -2 + expected_offset += 1 + elif lhs_type == "LinearProduct": + first_linear_or_quadratic = model.LinearProduct(2, x) + x_coefficient += 2 + elif lhs_type == "QuadraticTerm": + first_linear_or_quadratic = model.QuadraticTerm(xx, 2) + xx_coefficient += 2 + elif lhs_type == "QuadraticExpression": + first_linear_or_quadratic = model.QuadraticExpression( + x - 2 * y + 1 + 3 * x * x - 4 * x * y + ) + x_coefficient += 1 + y_coefficient += -2 + expected_offset += 1 + xx_coefficient += 3 + xy_coefficient += -4 + elif lhs_type == "QuadraticSum": + first_linear_or_quadratic = model.QuadraticSum( + (x, -2 * y, 1, y * y, -2 * x * y) + ) + x_coefficient += 1 + y_coefficient += -2 + expected_offset += 1 + yy_coefficient += 1 + xy_coefficient += -2 + elif lhs_type == "LinearLinearProduct": + first_linear_or_quadratic = model.LinearLinearProduct(y, x + y) + yy_coefficient += 1 + xy_coefficient += 1 + elif lhs_type == "QuadraticProduct": + first_linear_or_quadratic = model.QuadraticProduct(2, y * (x + y)) + yy_coefficient += 2 + xy_coefficient += 2 + else: + raise AssertionError(f"unknown linear type: {lhs_type!r}") + + # Setup second linear term + if rhs_type == "Variable": + second_linear_or_quadratic = y + y_coefficient += 1 * sign + elif rhs_type == "LinearTerm": + second_linear_or_quadratic = model.LinearTerm(y, 2) + y_coefficient += 2 * sign + elif rhs_type == "LinearExpression": + second_linear_or_quadratic = model.LinearExpression(y - 2 * x + 1) + x_coefficient += -2 * sign + y_coefficient += 1 * sign + expected_offset += 1 * sign + elif rhs_type == "LinearSum": + second_linear_or_quadratic = model.LinearSum((y, -2 * x, 1)) + x_coefficient += -2 * sign + y_coefficient += 1 * sign + expected_offset += 1 * sign + elif rhs_type == "LinearProduct": + second_linear_or_quadratic = model.LinearProduct(2, y) + y_coefficient += 2 * sign + elif rhs_type == "QuadraticTerm": + second_linear_or_quadratic = model.QuadraticTerm(xy, 5) + xy_coefficient += 5 * sign + elif rhs_type == "QuadraticExpression": + second_linear_or_quadratic = model.QuadraticExpression( + x - 2 * y + 1 + 3 * x * y - 4 * y * y + ) + x_coefficient += 1 * sign + y_coefficient += -2 * sign + expected_offset += 1 * sign + xy_coefficient += 3 * sign + yy_coefficient += -4 * sign + elif rhs_type == "QuadraticSum": + second_linear_or_quadratic = model.QuadraticSum( + (x, -2 * y, 1, y * x, -2 * x * x) + ) + x_coefficient += 1 * sign + y_coefficient += -2 * sign + expected_offset += 1 * sign + xy_coefficient += 1 * sign + xx_coefficient += -2 * sign + elif rhs_type == "LinearLinearProduct": + second_linear_or_quadratic = model.LinearLinearProduct(x, x + y) + xx_coefficient += sign + xy_coefficient += sign + elif rhs_type == "QuadraticProduct": + second_linear_or_quadratic = model.QuadraticProduct(2, x * (x + y)) + xx_coefficient += 2 * sign + xy_coefficient += 2 * sign + else: + raise AssertionError(f"unknown linear type: {lhs_type!r}") + + # Check __add__ and __sub__ + s = ( + first_linear_or_quadratic - second_linear_or_quadratic + if subtract + else first_linear_or_quadratic + second_linear_or_quadratic + ) + e = model.as_flat_quadratic_expression(s) + self.assertEqual(e.offset, expected_offset) + self.assertDictEqualWithZeroDefault( + dict(e.linear_terms), {x: x_coefficient, y: y_coefficient} + ) + self.assertDictEqualWithZeroDefault( + dict(e.quadratic_terms), + {xx: xx_coefficient, xy: xy_coefficient, yy: yy_coefficient}, + ) + + # Also check __iadd__ and __isub__ + if subtract: + first_linear_or_quadratic -= second_linear_or_quadratic + else: + first_linear_or_quadratic += second_linear_or_quadratic + e_inplace = model.as_flat_quadratic_expression(first_linear_or_quadratic) + self.assertEqual(e_inplace.offset, expected_offset) + self.assertDictEqualWithZeroDefault( + dict(e_inplace.linear_terms), {x: x_coefficient, y: y_coefficient} + ) + self.assertDictEqualWithZeroDefault( + dict(e_inplace.quadratic_terms), + {xx: xx_coefficient, xy: xy_coefficient, yy: yy_coefficient}, + ) + + +# Test multiplication of two Linear objects. +class LinearLinearMulTest(parameterized.TestCase): + def assertDictEqualWithZeroDefault( + self, dict1: dict[Any, float], dict2: dict[Any, float] + ) -> None: + for key in dict1.keys(): + if key not in dict2: + dict2[key] = 0.0 + for key in dict2.keys(): + if key not in dict1: + dict1[key] = 0.0 + self.assertDictEqual(dict1, dict2) + + @parameterized.named_parameters(("_x_first", True), ("_y_first", False)) + def test_var_var(self, x_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xy = model.QuadraticTermKey(x, y) + if x_first: + s = x * y + else: + s = y * x + self.assertIsInstance(s, model.QuadraticTerm) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault({xy: 1.0}, dict(e.quadratic_terms)) + self.assertDictEqualWithZeroDefault({}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_var_first", True), ("_term_first", False)) + def test_term_term(self, var_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xy = model.QuadraticTermKey(x, y) + if var_first: + s = model.LinearTerm(x, 2) * model.LinearTerm(y, 3) + else: + s = model.LinearTerm(x, 3) * model.LinearTerm(y, 2) + self.assertIsInstance(s, model.QuadraticTerm) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault({xy: 6.0}, dict(e.quadratic_terms)) + self.assertDictEqualWithZeroDefault({}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_expr1_first", True), ("_expr2_first", False)) + def test_expr_expr(self, expr1_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + expr1 = model.LinearExpression(x - 2 * y + 3) + expr2 = model.LinearExpression(-x + y + 1) + if expr1_first: + s = expr1 * expr2 + else: + s = expr2 * expr1 + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault( + { + xx: -1.0, + xy: 3.0, + yy: -2.0, + }, + dict(e.quadratic_terms), + ) + self.assertDictEqualWithZeroDefault({x: -2.0, y: 1.0}, dict(e.linear_terms)) + self.assertEqual(3.0, e.offset) + + @parameterized.named_parameters(("_sum1_first", True), ("_sum2_first", False)) + def test_sum_sum(self, sum1_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + sum1 = model.LinearSum((x, -2 * y, 3)) + sum2 = model.LinearSum((-x, y, 1)) + if sum1_first: + s = sum1 * sum2 + else: + s = sum2 * sum1 + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault( + { + xx: -1.0, + xy: 3.0, + yy: -2.0, + }, + dict(e.quadratic_terms), + ) + self.assertDictEqualWithZeroDefault({x: -2.0, y: 1.0}, dict(e.linear_terms)) + self.assertEqual(3.0, e.offset) + + @parameterized.named_parameters(("_prod1_first", True), ("_prod2_first", False)) + def test_prod_prod(self, prod1_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + prod1 = model.LinearProduct(2.0, x) + prod2 = model.LinearProduct(3.0, x + 2 * y - 1) + if prod1_first: + s = prod1 * prod2 + else: + s = prod2 * prod1 + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault( + { + xx: 6.0, + xy: 12.0, + }, + dict(e.quadratic_terms), + ) + self.assertDictEqualWithZeroDefault({x: -6.0}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_var_first", True), ("_term_first", False)) + def test_var_term(self, var_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xy = model.QuadraticTermKey(x, y) + term = model.LinearTerm(y, 2) + if var_first: + s = x * term + else: + s = term * x + self.assertIsInstance(s, model.QuadraticTerm) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault({xy: 2.0}, dict(e.quadratic_terms)) + self.assertDictEqualWithZeroDefault({}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_var_first", True), ("_expr_first", False)) + def test_var_expr(self, var_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + expr = model.LinearExpression(x - 2 * y + 3) + if var_first: + s = x * expr + else: + s = expr * x + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault( + {xx: 1.0, xy: -2.0}, dict(e.quadratic_terms) + ) + self.assertDictEqualWithZeroDefault({x: 3.0}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_var_first", True), ("_sum_first", False)) + def test_var_sum(self, var_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + linear_sum = model.LinearSum((x, -2 * y, 3)) + if var_first: + s = x * linear_sum + else: + s = linear_sum * x + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault( + {xx: 1.0, xy: -2.0}, dict(e.quadratic_terms) + ) + self.assertDictEqualWithZeroDefault({x: 3.0}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_var_first", True), ("_prod_first", False)) + def test_var_prod(self, var_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xy = model.QuadraticTermKey(x, y) + expr = model.LinearProduct(2.0, y) + if var_first: + s = x * expr + else: + s = expr * x + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault({xy: 2.0}, dict(e.quadratic_terms)) + self.assertDictEqualWithZeroDefault({}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_term_first", True), ("_expr_first", False)) + def test_term_expr(self, term_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + term = model.LinearTerm(x, 2) + expr = model.LinearExpression(x - 2 * y + 3) + if term_first: + s = term * expr + else: + s = expr * term + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault( + {xx: 2.0, xy: -4.0}, dict(e.quadratic_terms) + ) + self.assertDictEqualWithZeroDefault({x: 6.0}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_term_first", True), ("_sum_first", False)) + def test_term_sum(self, term_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + term = model.LinearTerm(x, 2) + linear_sum = model.LinearSum((x, -2 * y, 3)) + if term_first: + s = term * linear_sum + else: + s = linear_sum * term + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault( + {xx: 2.0, xy: -4.0}, dict(e.quadratic_terms) + ) + self.assertDictEqualWithZeroDefault({x: 6.0}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_term_first", True), ("_prod_first", False)) + def test_term_prod(self, term_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xy = model.QuadraticTermKey(x, y) + term = model.LinearTerm(x, 2) + prod = model.LinearProduct(2.0, y) + if term_first: + s = term * prod + else: + s = prod * term + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault({xy: 4.0}, dict(e.quadratic_terms)) + self.assertDictEqualWithZeroDefault({}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_expr_first", True), ("_sum_first", False)) + def test_expr_sum(self, expr_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + expr = model.LinearExpression(-x + y + 1) + linear_sum = model.LinearSum((x, -2 * y, 3)) + if expr_first: + s = expr * linear_sum + else: + s = linear_sum * expr + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault( + { + xx: -1.0, + xy: 3.0, + yy: -2.0, + }, + dict(e.quadratic_terms), + ) + self.assertDictEqualWithZeroDefault({x: -2.0, y: 1.0}, dict(e.linear_terms)) + self.assertEqual(3.0, e.offset) + + @parameterized.named_parameters(("_expr_first", True), ("_prod_first", False)) + def test_expr_prod(self, expr_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + expr = model.LinearExpression(-x + y + 1) + prod = model.LinearProduct(2.0, y) + if expr_first: + s = expr * prod + else: + s = prod * expr + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault( + {xy: -2.0, yy: 2.0}, dict(e.quadratic_terms) + ) + self.assertDictEqualWithZeroDefault({y: 2.0}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + @parameterized.named_parameters(("_sum_first", True), ("_prod_first", False)) + def test_sum_prod(self, sum_first: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + linear_sum = model.LinearSum((-x, y, 1)) + prod = model.LinearProduct(2.0, y) + if sum_first: + s = linear_sum * prod + else: + s = prod * linear_sum + self.assertIsInstance(s, model.LinearLinearProduct) + e = model.as_flat_quadratic_expression(s) + self.assertDictEqualWithZeroDefault( + {xy: -2.0, yy: 2.0}, dict(e.quadratic_terms) + ) + self.assertDictEqualWithZeroDefault({y: 2.0}, dict(e.linear_terms)) + self.assertEqual(0.0, e.offset) + + +# Test negate on Linear and Quadratic objects. +class NegateTest(parameterized.TestCase): + def test_negate_var(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + s = -x + self.assertIsInstance(s, model.LinearTerm) + e = model.as_flat_linear_expression(s) + self.assertEqual(0, e.offset) + self.assertDictEqual({x: -1}, dict(e.terms)) + + def test_negate_linear_term(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + term = model.LinearTerm(x, 0.5) + s = -term + self.assertIsInstance(s, model.LinearTerm) + e = model.as_flat_linear_expression(s) + self.assertEqual(0, e.offset) + self.assertDictEqual({x: -0.5}, dict(e.terms)) + + def test_negate_linear_expression(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + expression = model.LinearExpression(y - 2 * x + 1) + s = -expression + self.assertIsInstance(s, model.LinearProduct) + e = model.as_flat_linear_expression(s) + self.assertEqual(-1, e.offset) + self.assertDictEqual({x: 2, y: -1}, dict(e.terms)) + + def test_negate_linear_sum(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + expression = model.LinearSum((y, -2 * x, 1)) + s = -expression + self.assertIsInstance(s, model.LinearProduct) + e = model.as_flat_linear_expression(s) + self.assertEqual(-1, e.offset) + self.assertDictEqual({x: 2, y: -1}, dict(e.terms)) + + def test_negate_ast_product(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + ast_product = model.LinearProduct(0.5, x) + s = -ast_product + self.assertIsInstance(s, model.LinearProduct) + e = model.as_flat_linear_expression(s) + self.assertEqual(0, e.offset) + self.assertDictEqual({x: -0.5}, dict(e.terms)) + + def test_negate_quadratic_term(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + xx = model.QuadraticTermKey(x, x) + term = model.QuadraticTerm(xx, 0.5) + s = -term + self.assertIsInstance(s, model.QuadraticTerm) + e = model.as_flat_quadratic_expression(s) + self.assertEqual(0, e.offset) + self.assertDictEqual({}, dict(e.linear_terms)) + self.assertDictEqual({xx: -0.5}, dict(e.quadratic_terms)) + + def test_negate_quadratic_expression(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + xy = model.QuadraticTermKey(x, y) + expression = model.QuadraticExpression(y - 2 * x + 1 + x * y) + s = -expression + self.assertIsInstance(s, model.QuadraticProduct) + e = model.as_flat_quadratic_expression(s) + self.assertEqual(-1, e.offset) + self.assertDictEqual({x: 2, y: -1}, dict(e.linear_terms)) + self.assertDictEqual({xy: -1}, dict(e.quadratic_terms)) + + def test_negate_quadratic_sum(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + yy = model.QuadraticTermKey(y, y) + expression = model.QuadraticSum((y, -2 * x, 1, -y * y)) + s = -expression + self.assertIsInstance(s, model.QuadraticProduct) + e = model.as_flat_quadratic_expression(s) + self.assertEqual(-1, e.offset) + self.assertDictEqual({x: 2, y: -1}, dict(e.linear_terms)) + self.assertDictEqual({yy: 1}, dict(e.quadratic_terms)) + + def test_negate_linear_linear_product(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + xx = model.QuadraticTermKey(x, x) + ast_product = model.LinearLinearProduct(x, x + 1) + s = -ast_product + self.assertIsInstance(s, model.QuadraticProduct) + e = model.as_flat_quadratic_expression(s) + self.assertEqual(0, e.offset) + self.assertDictEqual({x: -1}, dict(e.linear_terms)) + self.assertDictEqual({xx: -1}, dict(e.quadratic_terms)) + + def test_negate_quadratic_product(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + xx = model.QuadraticTermKey(x, x) + ast_product = model.QuadraticProduct(0.5, x + x * x) + s = -ast_product + self.assertIsInstance(s, model.QuadraticProduct) + e = model.as_flat_quadratic_expression(s) + self.assertEqual(0, e.offset) + self.assertDictEqual({x: -0.5}, dict(e.linear_terms)) + self.assertDictEqual({xx: -0.5}, dict(e.quadratic_terms)) + + +class UnsupportedProductOperandTestParams(NamedTuple): + lhs_type: str + rhs_type: str + + def test_suffix(self): + return f"_{self.lhs_type}_{self.rhs_type}" + + +def all_unsupported_product_operand_params() -> ( + List[UnsupportedProductOperandTestParams] +): + result = [] + for lhs_type in _LINEAR_TYPES: + result.append( + UnsupportedProductOperandTestParams(lhs_type=lhs_type, rhs_type="complex") + ) + for lhs_type in _QUADRATIC_TYPES + ("complex",): + for rhs_type in _QUADRATIC_TYPES + ("complex",): + if lhs_type == "complex" and rhs_type == "complex": + continue + result.append( + UnsupportedProductOperandTestParams( + lhs_type=lhs_type, rhs_type=rhs_type + ) + ) + return result + + +def all_unsupported_division_operand_params() -> ( + List[UnsupportedProductOperandTestParams] +): + result = [] + for lhs_type in _LINEAR_TYPES + _QUADRATIC_TYPES + ("complex",): + for rhs_type in _LINEAR_TYPES + _QUADRATIC_TYPES + ("complex",): + if lhs_type == "complex" and rhs_type == "complex": + continue + result.append( + UnsupportedProductOperandTestParams( + lhs_type=lhs_type, rhs_type=rhs_type + ) + ) + return result + + +def get_linear_or_quadratic_for_unsupported_operand_test( + type_string: str, +) -> Union[model.LinearBase, model.QuadraticBase, complex]: + mod_ = model.Model() + x = mod_.add_binary_variable(name="x") + y = mod_.add_binary_variable(name="y") + xy = model.QuadraticTermKey(x, y) + if type_string == "Variable": + return x + elif type_string == "LinearTerm": + return model.LinearTerm(x, 2) + elif type_string == "LinearExpression": + return model.LinearExpression(x - 2 * y + 3) + elif type_string == "LinearSum": + return model.LinearSum((x, -2 * y, 3)) + elif type_string == "LinearProduct": + return model.LinearProduct(2, x) + elif type_string == "QuadraticTerm": + return model.QuadraticTerm(xy, 5) + elif type_string == "QuadraticExpression": + return model.QuadraticExpression(x - 2 * y + 1 + 3 * x * y - 4 * y * y) + elif type_string == "QuadraticSum": + return model.QuadraticSum((x, -2 * y, 1, y * x, -2 * x * x)) + elif type_string == "LinearLinearProduct": + return model.LinearLinearProduct(x, x + y) + elif type_string == "QuadraticProduct": + return model.QuadraticProduct(2, x * (x + y)) + elif type_string == "complex": + return 6j + else: + raise AssertionError(f"unknown linear or quadratic type: {type_string!r}") + + +class UnsupportedProductOperandTest(parameterized.TestCase): + @parameterized.named_parameters( + (p.test_suffix(), p.lhs_type, p.rhs_type) + for p in all_unsupported_product_operand_params() + ) + def test_mult(self, lhs_type: str, rhs_type: str) -> None: + lhs = get_linear_or_quadratic_for_unsupported_operand_test(lhs_type) + rhs = get_linear_or_quadratic_for_unsupported_operand_test(rhs_type) + + expected_string = f"unsupported operand.*[*].*{lhs_type}.*and.*{rhs_type}" + + # pylint: disable=pointless-statement + # pytype: disable=unsupported-operands + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex(TypeError, expected_string): + lhs * rhs + + with self.assertRaisesRegex(TypeError, expected_string): + lhs *= rhs + # pylint: enable=pointless-statement + # pytype: enable=unsupported-operands + # pytype: enable=wrong-arg-types + + @parameterized.named_parameters( + (p.test_suffix(), p.lhs_type, p.rhs_type) + for p in all_unsupported_division_operand_params() + ) + def test_div(self, lhs_type: str, rhs_type: str) -> None: + lhs = get_linear_or_quadratic_for_unsupported_operand_test(lhs_type) + rhs = get_linear_or_quadratic_for_unsupported_operand_test(rhs_type) + + expected_string = f"unsupported operand.*[/].*{lhs_type}.*and.*{rhs_type}" + + # pylint: disable=pointless-statement + # pytype: disable=unsupported-operands + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex(TypeError, expected_string): + lhs / rhs + + if lhs_type == "str": + expected_string = f"unsupported operand.*[/].*{lhs_type}.*and.*{rhs_type}" + with self.assertRaisesRegex(TypeError, expected_string): + lhs /= rhs + # pylint: enable=pointless-statement + # pytype: enable=unsupported-operands + # pytype: enable=wrong-arg-types + + +class UnsupportedAdditionOperandTestParams(NamedTuple): + linear_or_quadratic_type: str + linear_or_quadratic_first: bool + + def test_suffix(self): + if self.linear_or_quadratic_first: + return f"_{self.linear_or_quadratic_type}_str" + else: + return f"_str_{self.linear_or_quadratic_type}" + + +def all_unsupported_addition_operand_params() -> ( + List[UnsupportedAdditionOperandTestParams] +): + result = [] + for linear_or_quadratic_type in _LINEAR_TYPES + _QUADRATIC_TYPES: + for linear_or_quadratic_first in (True, False): + result.append( + UnsupportedAdditionOperandTestParams( + linear_or_quadratic_type=linear_or_quadratic_type, + linear_or_quadratic_first=linear_or_quadratic_first, + ) + ) + return result + + +@parameterized.named_parameters( + (p.test_suffix(), p.linear_or_quadratic_type, p.linear_or_quadratic_first) + for p in all_unsupported_addition_operand_params() +) +class UnsupportedAdditionOperandTest(parameterized.TestCase): + def test_add( + self, linear_or_quadratic_type: str, linear_or_quadratic_first: bool + ) -> None: + linear_or_quadratic = get_linear_or_quadratic_for_unsupported_operand_test( + linear_or_quadratic_type + ) + other = 6j + + expected_string = r"unsupported operand type\(s\) for \+.*" + if linear_or_quadratic_first: + expected_string += ( + f"{linear_or_quadratic_type}.*and.*{type(other).__name__}.*" + ) + else: + expected_string += ( + f"{type(other).__name__}.*and.*{linear_or_quadratic_type}.*" + ) + + # pylint: disable=pointless-statement + # pytype: disable=unsupported-operands + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex(TypeError, expected_string): + if linear_or_quadratic_first: + linear_or_quadratic + other + else: + other + linear_or_quadratic + + with self.assertRaisesRegex(TypeError, expected_string): + if linear_or_quadratic_first: + linear_or_quadratic += other + else: + other += linear_or_quadratic + # pylint: enable=pointless-statement + # pytype: enable=unsupported-operands + # pytype: enable=wrong-arg-types + + def test_sub( + self, linear_or_quadratic_type: str, linear_or_quadratic_first: bool + ) -> None: + linear_or_quadratic = get_linear_or_quadratic_for_unsupported_operand_test( + linear_or_quadratic_type + ) + other = 6j + + expected_string = "unsupported operand type[(]s[)] for [-].*" + if linear_or_quadratic_first: + expected_string += ( + f"{linear_or_quadratic_type}.*and.*{type(other).__name__}.*" + ) + else: + expected_string += ( + f"{type(other).__name__}.*and.*{linear_or_quadratic_type}.*" + ) + + # pylint: disable=pointless-statement + # pytype: disable=unsupported-operands + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex(TypeError, expected_string): + if linear_or_quadratic_first: + linear_or_quadratic - other + else: + other - linear_or_quadratic + + with self.assertRaisesRegex(TypeError, expected_string): + if linear_or_quadratic_first: + linear_or_quadratic -= other + else: + other -= linear_or_quadratic + # pylint: enable=pointless-statement + # pytype: enable=unsupported-operands + # pytype: enable=wrong-arg-types + + +class UnsupportedInitializationTest(parameterized.TestCase): + def test_linear_sum_not_tuple(self): + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex(TypeError, "object is not iterable"): + model.LinearSum(2.0) + # pytype: enable=wrong-arg-types + + def test_linear_sum_not_linear_in_tuple(self): + mod = model.Model() + x = mod.add_binary_variable(name="x") + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex(TypeError, "unsupported type in iterable argument"): + model.LinearSum((2.0, x * x)) + # pytype: enable=wrong-arg-types + + def test_quadratic_sum_not_tuple(self): + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex(TypeError, "object is not iterable"): + model.QuadraticSum(2.0) + # pytype: enable=wrong-arg-types + + def test_quadratic_sum_not_linear_in_tuple(self): + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex(TypeError, "unsupported type in iterable argument"): + model.QuadraticSum((2.0, "string")) + # pytype: enable=wrong-arg-types + + def test_linear_product_not_scalar(self): + mod = model.Model() + x = mod.add_binary_variable(name="x") + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex( + TypeError, "unsupported type for scalar argument in LinearProduct" + ): + model.LinearProduct(x, x) + # pytype: enable=wrong-arg-types + + def test_linear_product_not_linear(self): + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex( + TypeError, "unsupported type for linear argument in LinearProduct" + ): + model.LinearProduct(2.0, "string") + # pytype: enable=wrong-arg-types + + def test_quadratic_product_not_scalar(self): + mod = model.Model() + x = mod.add_binary_variable(name="x") + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex( + TypeError, "unsupported type for scalar argument in QuadraticProduct" + ): + model.QuadraticProduct(x, x) + # pytype: enable=wrong-arg-types + + def test_quadratic_product_not_quadratic(self): + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex( + TypeError, "unsupported type for linear argument in QuadraticProduct" + ): + model.QuadraticProduct(2.0, "string") + # pytype: enable=wrong-arg-types + + def test_linear_linear_product_first_not_linear(self): + mod = model.Model() + x = mod.add_binary_variable(name="x") + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex( + TypeError, + "unsupported type for first_linear argument in LinearLinearProduct", + ): + model.LinearLinearProduct("string", x) + # pytype: enable=wrong-arg-types + + def test_linear_linear_product_second_not_linear(self): + mod = model.Model() + x = mod.add_binary_variable(name="x") + # pytype: disable=wrong-arg-types + with self.assertRaisesRegex( + TypeError, + "unsupported type for second_linear argument in LinearLinearProduct", + ): + model.LinearLinearProduct(x, "string") + # pytype: enable=wrong-arg-types + + +@parameterized.named_parameters(("_python_sum", True), ("LinearSum", False)) +class SumTest(parameterized.TestCase): + def test_sum_vars(self, python_sum: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + array = [x, z, x, x, y] + if python_sum: + s = sum(array) + 8.0 + else: + s = model.LinearSum(array) + 8.0 + e = model.as_flat_linear_expression(s) + self.assertEqual(8.0, e.offset) + self.assertDictEqual({x: 3, y: 1, z: 1}, dict(e.terms)) + + def test_sum_linear_terms(self, python_sum: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + array = [1.25 * x, z, x, x, y, -0.5 * y, 1.0] + if python_sum: + s = sum(array) + 8.0 + else: + s = model.LinearSum(array) + 8.0 + e = model.as_flat_linear_expression(s) + self.assertEqual(9.0, e.offset) + self.assertDictEqual({x: 3.25, y: 0.5, z: 1}, dict(e.terms)) + + def test_sum_quadratic_terms(self, python_sum: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + array = [1.25 * x, z, x, x, y, -0.5 * y, 1.0, 2.5 * x * x, -x * y] + if python_sum: + s = sum(array) + 8.0 + else: + s = model.QuadraticSum(array) + 8.0 + e = model.as_flat_quadratic_expression(s) + self.assertEqual(9.0, e.offset) + self.assertDictEqual({x: 3.25, y: 0.5, z: 1}, dict(e.linear_terms)) + self.assertDictEqual({xx: 2.5, xy: -1}, dict(e.quadratic_terms)) + + def test_sum_linear_expression(self, python_sum: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + array = [1.25 * x + z, x, x + y, -0.5 * y + 1.0] + if python_sum: + s = sum(array) + 8.0 + else: + s = model.LinearSum(array) + 8.0 + e = model.as_flat_linear_expression(s) + self.assertEqual(9.0, e.offset) + self.assertDictEqual({x: 3.25, y: 0.5, z: 1}, dict(e.terms)) + + def test_sum_quadratic_expression(self, python_sum: bool) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + array = [ + 1.25 * x + z, + x, + x + y, + -0.5 * y + 1.0, + 2.5 * x * x - x * y, + x * z + y * z, + ] + if python_sum: + s = sum(array) + 8.0 + else: + s = model.QuadraticSum(array) + 8.0 + e = model.as_flat_quadratic_expression(s) + self.assertEqual(9.0, e.offset) + self.assertDictEqual({x: 3.25, y: 0.5, z: 1}, dict(e.linear_terms)) + self.assertDictEqual( + { + xx: 2.5, + xy: -1, + model.QuadraticTermKey(x, z): 1.0, + model.QuadraticTermKey(y, z): 1, + }, + dict(e.quadratic_terms), + ) + + def test_generator_sum_vars(self, python_sum: bool) -> None: + mod = model.Model() + x = [mod.add_binary_variable(name=f"x[{i}]") for i in range(3)] + if python_sum: + s = sum(x[i] for i in range(3)) + 8.0 + else: + s = model.LinearSum(x[i] for i in range(3)) + 8.0 + e = model.as_flat_linear_expression(s) + self.assertEqual(8.0, e.offset) + self.assertDictEqual({x[0]: 1, x[1]: 1, x[2]: 1}, dict(e.terms)) + + def test_generator_sum_terms(self, python_sum: bool) -> None: + mod = model.Model() + x = [mod.add_binary_variable(name=f"x[{i}]") for i in range(3)] + if python_sum: + s = sum(i * x[i] for i in range(3)) + 8.0 + else: + s = model.LinearSum(i * x[i] for i in range(3)) + 8.0 + e = model.as_flat_linear_expression(s) + self.assertEqual(8.0, e.offset) + self.assertDictEqual({x[0]: 0, x[1]: 1, x[2]: 2}, dict(e.terms)) + + def test_generator_sum_quadratic_terms(self, python_sum: bool) -> None: + mod = model.Model() + x = [mod.add_binary_variable(name=f"x[{i}]") for i in range(4)] + if python_sum: + s = sum(i * x[i] * x[i + 1] for i in range(3)) + 8.0 + else: + s = model.QuadraticSum(i * x[i] * x[i + 1] for i in range(3)) + 8.0 + e = model.as_flat_quadratic_expression(s) + self.assertEqual(8.0, e.offset) + self.assertDictEqual({}, dict(e.linear_terms)) + self.assertDictEqual( + { + model.QuadraticTermKey(x[0], x[1]): 0, + model.QuadraticTermKey(x[1], x[2]): 1, + model.QuadraticTermKey(x[2], x[3]): 2, + }, + dict(e.quadratic_terms), + ) + + def test_generator_sum_expression(self, python_sum: bool) -> None: + mod = model.Model() + x = [mod.add_binary_variable(name=f"x[{i}]") for i in range(3)] + if python_sum: + s = sum(2 * x[i] - x[i + 1] + 1.5 for i in range(2)) + 8.0 + else: + s = model.LinearSum(2 * x[i] - x[i + 1] + 1.5 for i in range(2)) + 8.0 + e = model.as_flat_linear_expression(s) + self.assertEqual(11.0, e.offset) + self.assertDictEqual({x[0]: 2, x[1]: 1, x[2]: -1}, dict(e.terms)) + + def test_generator_quadratic_sum_expression(self, python_sum: bool) -> None: + mod = model.Model() + x = [mod.add_binary_variable(name=f"x[{i}]") for i in range(3)] + if python_sum: + s = sum(2 * x[i] - x[i + 1] + 1.5 + x[i] * x[i + 1] for i in range(2)) + 8.0 + else: + s = ( + model.QuadraticSum( + 2 * x[i] - x[i + 1] + 1.5 + x[i] * x[i + 1] for i in range(2) + ) + + 8.0 + ) + e = model.as_flat_quadratic_expression(s) + self.assertEqual(11.0, e.offset) + self.assertDictEqual({x[0]: 2, x[1]: 1, x[2]: -1}, dict(e.linear_terms)) + self.assertDictEqual( + { + model.QuadraticTermKey(x[0], x[1]): 1, + model.QuadraticTermKey(x[1], x[2]): 1, + }, + dict(e.quadratic_terms), + ) + + +class AstTest(parameterized.TestCase): + def assertDictEqualWithZeroDefault( + self, dict1: dict[Any, float], dict2: dict[Any, float] + ) -> None: + for key in dict1.keys(): + if key not in dict2: + dict2[key] = 0.0 + for key in dict2.keys(): + if key not in dict1: + dict1[key] = 0.0 + self.assertDictEqual(dict1, dict2) + + def test_simple_linear_ast(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + s = (6 * (3 * x + y + 3) + 6 * z + 12) / 3 + y + 7 + (x + y) * 2.0 - z * 3.0 + e = model.as_flat_linear_expression(s) + self.assertEqual(6 * 3 / 3 + 12 / 3 + 7, e.offset) + self.assertDictEqualWithZeroDefault( + {x: 8, y: 3 + 6 / 3, z: 6 / 3 - 3}, dict(e.terms) + ) + + def test_simple_quadratic_ast(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + xx = model.QuadraticTermKey(x, x) + xy = model.QuadraticTermKey(x, y) + yy = model.QuadraticTermKey(y, y) + s = ( + (6 * (3 * x + y + 3) * x + 6 * z + 12) / 3 + + y * y + + 7 + + (x - y) * (x + y) * 2.0 + - z * 3.0 + ) + e = model.as_flat_quadratic_expression(s) + self.assertEqual(12 / 3 + 7, e.offset) + self.assertDictEqualWithZeroDefault({x: 6, z: 6 / 3 - 3}, dict(e.linear_terms)) + self.assertDictEqualWithZeroDefault( + {xx: 6 * 3 / 3 + 2, xy: 6 / 3, yy: 1 - 2}, dict(e.quadratic_terms) + ) + + def test_linear_sum_ast(self) -> None: + mod = model.Model() + x = [mod.add_binary_variable(name=f"x[{i}]") for i in range(5)] + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + s = ( + (2 * (z + y) + 2 * y) / 2 + + sum( + x[i] + 0.5 * model.LinearSum([4 * x[1] - 2 * x[0], 2 * x[2], 2.5, -0.5]) + for i in range(3) + ) + - model.LinearSum([x[3], x[4]]) + ) + e = model.as_flat_linear_expression(s) + self.assertEqual(3.0, e.offset) + self.assertDictEqualWithZeroDefault( + {x[0]: -2, x[1]: 7, x[2]: 4, x[3]: -1, x[4]: -1, y: 2, z: 1}, + dict(e.terms), + ) + + def test_quadratic_sum_ast(self) -> None: + mod = model.Model() + x = [mod.add_binary_variable(name=f"x[{i}]") for i in range(3)] + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + yy = model.QuadraticTermKey(y, y) + zz = model.QuadraticTermKey(z, z) + s = ( + 1 + + y * y + + z + + sum(x[i] + x[i] * model.LinearSum([y, z]) for i in range(3)) + - model.QuadraticSum([y, z * z]) + ) + e = model.as_flat_quadratic_expression(s) + self.assertEqual(1.0, e.offset) + self.assertDictEqualWithZeroDefault( + {x[0]: 1, x[1]: 1, x[2]: 1, y: -1, z: 1}, dict(e.linear_terms) + ) + self.assertDictEqualWithZeroDefault( + { + yy: 1, + zz: -1, + model.QuadraticTermKey(x[0], y): 1, + model.QuadraticTermKey(x[1], y): 1, + model.QuadraticTermKey(x[2], y): 1, + model.QuadraticTermKey(x[0], z): 1, + model.QuadraticTermKey(x[1], z): 1, + model.QuadraticTermKey(x[2], z): 1, + }, + dict(e.quadratic_terms), + ) + + +# Test behavior of LinearExpression and as_flat_linear_expression that is +# not covered by other tests. +class LinearExpressionTest(parameterized.TestCase): + def test_init_to_zero(self) -> None: + expression = model.LinearExpression() + self.assertEqual(expression.offset, 0.0) + self.assertEmpty(expression.terms) + + def test_terms_read_only(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + expression = model.LinearExpression(y - 2 * x + 1) + with self.assertRaisesRegex(TypeError, "does not support item assignment"): + expression.terms[x] += 1 # pytype: disable=unsupported-operands + + def test_no_copy_of_linear_expression(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + expression = model.LinearExpression(y - 2 * x + 1) + self.assertIs(expression, model.as_flat_linear_expression(expression)) + + def test_number_as_flat_linear_expression(self) -> None: + expression = model.LinearExpression(2.0) + self.assertDictEqual(dict(expression.terms), {}) + self.assertEqual(expression.offset, 2.0) + + +# Test behavior of QuadraticExpression and as_flat_quadratic_expression that is +# not covered by other tests. +class QuadraticExpressionTest(parameterized.TestCase): + def test_terms_read_only(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + yy = model.QuadraticTermKey(y, y) + expression = model.QuadraticExpression(y * y - 2 * x + 1) + with self.assertRaisesRegex(TypeError, "does not support item assignment"): + expression.linear_terms[x] += 1 # pytype: disable=unsupported-operands + with self.assertRaisesRegex(TypeError, "does not support item assignment"): + expression.quadratic_terms[yy] += 1 # pytype: disable=unsupported-operands + + def test_no_copy_of_quadratic_expression(self) -> None: + mod = model.Model() + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + expression = model.QuadraticExpression(y * y - 2 * x + 1) + self.assertIs(expression, model.as_flat_quadratic_expression(expression)) + + def test_number_as_flat_quadratic_expression(self) -> None: + expression = model.QuadraticExpression(2.0) + self.assertDictEqual(dict(expression.linear_terms), {}) + self.assertDictEqual(dict(expression.quadratic_terms), {}) + self.assertEqual(expression.offset, 2.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/mathopt.py b/ortools/math_opt/python/mathopt.py new file mode 100644 index 0000000000..20ac97830a --- /dev/null +++ b/ortools/math_opt/python/mathopt.py @@ -0,0 +1,169 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Module exporting all classes and functions needed for MathOpt. + +This module defines aliases to all classes and functions needed for regular use +of MathOpt. It removes the need for users to have multiple imports for specific +sub-modules. + +For example instead of: + from ortools.math_opt.python import model + from ortools.math_opt.python import solve + + m = model.Model() + solve.solve(m) + +we can simply do: + from ortools.math_opt.python import mathopt + + m = mathopt.Model() + mathopt.solve(m) +""" + +# pylint: disable=unused-import +# pylint: disable=g-importing-member + +from ortools.math_opt.python.callback import BarrierStats +from ortools.math_opt.python.callback import CallbackData +from ortools.math_opt.python.callback import CallbackRegistration +from ortools.math_opt.python.callback import CallbackResult +from ortools.math_opt.python.callback import Event +from ortools.math_opt.python.callback import GeneratedConstraint +from ortools.math_opt.python.callback import MipStats +from ortools.math_opt.python.callback import parse_callback_data +from ortools.math_opt.python.callback import PresolveStats +from ortools.math_opt.python.callback import SimplexStats +from ortools.math_opt.python.compute_infeasible_subsystem_result import ( + ComputeInfeasibleSubsystemResult, +) +from ortools.math_opt.python.compute_infeasible_subsystem_result import ModelSubset +from ortools.math_opt.python.compute_infeasible_subsystem_result import ( + ModelSubsetBounds, +) +from ortools.math_opt.python.compute_infeasible_subsystem_result import ( + parse_compute_infeasible_subsystem_result, +) +from ortools.math_opt.python.compute_infeasible_subsystem_result import ( + parse_model_subset, +) +from ortools.math_opt.python.compute_infeasible_subsystem_result import ( + parse_model_subset_bounds, +) +from ortools.math_opt.python.hash_model_storage import HashModelStorage +from ortools.math_opt.python.message_callback import list_message_callback +from ortools.math_opt.python.message_callback import log_messages +from ortools.math_opt.python.message_callback import printer_message_callback +from ortools.math_opt.python.message_callback import SolveMessageCallback +from ortools.math_opt.python.message_callback import vlog_messages +from ortools.math_opt.python.model import as_flat_linear_expression +from ortools.math_opt.python.model import as_flat_quadratic_expression +from ortools.math_opt.python.model import as_normalized_linear_inequality +from ortools.math_opt.python.model import BoundedLinearExpression +from ortools.math_opt.python.model import BoundedLinearTypes +from ortools.math_opt.python.model import BoundedLinearTypesList +from ortools.math_opt.python.model import LinearBase +from ortools.math_opt.python.model import LinearConstraint +from ortools.math_opt.python.model import LinearConstraintMatrixEntry +from ortools.math_opt.python.model import LinearExpression +from ortools.math_opt.python.model import LinearLinearProduct +from ortools.math_opt.python.model import LinearProduct +from ortools.math_opt.python.model import LinearSum +from ortools.math_opt.python.model import LinearTerm +from ortools.math_opt.python.model import LinearTypes +from ortools.math_opt.python.model import LinearTypesExceptVariable +from ortools.math_opt.python.model import LowerBoundedLinearExpression +from ortools.math_opt.python.model import Model +from ortools.math_opt.python.model import NormalizedLinearInequality +from ortools.math_opt.python.model import Objective +from ortools.math_opt.python.model import QuadraticBase +from ortools.math_opt.python.model import QuadraticExpression +from ortools.math_opt.python.model import QuadraticProduct +from ortools.math_opt.python.model import QuadraticSum +from ortools.math_opt.python.model import QuadraticTerm +from ortools.math_opt.python.model import QuadraticTermKey +from ortools.math_opt.python.model import QuadraticTypes +from ortools.math_opt.python.model import Storage +from ortools.math_opt.python.model import StorageClass +from ortools.math_opt.python.model import UpdateTracker +from ortools.math_opt.python.model import UpperBoundedLinearExpression +from ortools.math_opt.python.model import VarEqVar +from ortools.math_opt.python.model import Variable +from ortools.math_opt.python.model_parameters import ModelSolveParameters +from ortools.math_opt.python.model_parameters import parse_solution_hint +from ortools.math_opt.python.model_parameters import SolutionHint +from ortools.math_opt.python.model_storage import BadLinearConstraintIdError +from ortools.math_opt.python.model_storage import BadVariableIdError +from ortools.math_opt.python.model_storage import LinearConstraintMatrixIdEntry +from ortools.math_opt.python.model_storage import LinearObjectiveEntry +from ortools.math_opt.python.model_storage import ModelStorage +from ortools.math_opt.python.model_storage import ModelStorageImpl +from ortools.math_opt.python.model_storage import ModelStorageImplClass +from ortools.math_opt.python.model_storage import QuadraticEntry +from ortools.math_opt.python.model_storage import QuadraticTermIdKey +from ortools.math_opt.python.model_storage import StorageUpdateTracker +from ortools.math_opt.python.model_storage import UsedUpdateTrackerAfterRemovalError +from ortools.math_opt.python.parameters import Emphasis +from ortools.math_opt.python.parameters import emphasis_from_proto +from ortools.math_opt.python.parameters import emphasis_to_proto +from ortools.math_opt.python.parameters import GlpkParameters +from ortools.math_opt.python.parameters import GurobiParameters +from ortools.math_opt.python.parameters import lp_algorithm_from_proto +from ortools.math_opt.python.parameters import lp_algorithm_to_proto +from ortools.math_opt.python.parameters import LPAlgorithm +from ortools.math_opt.python.parameters import SolveParameters +from ortools.math_opt.python.parameters import solver_type_from_proto +from ortools.math_opt.python.parameters import solver_type_to_proto +from ortools.math_opt.python.parameters import SolverType +from ortools.math_opt.python.result import FeasibilityStatus +from ortools.math_opt.python.result import Limit +from ortools.math_opt.python.result import ObjectiveBounds +from ortools.math_opt.python.result import parse_objective_bounds +from ortools.math_opt.python.result import parse_problem_status +from ortools.math_opt.python.result import parse_solve_result +from ortools.math_opt.python.result import parse_solve_stats +from ortools.math_opt.python.result import parse_termination +from ortools.math_opt.python.result import ProblemStatus +from ortools.math_opt.python.result import SolveResult +from ortools.math_opt.python.result import SolveStats +from ortools.math_opt.python.result import Termination +from ortools.math_opt.python.result import TerminationReason +from ortools.math_opt.python.solution import Basis +from ortools.math_opt.python.solution import BasisStatus +from ortools.math_opt.python.solution import DualRay +from ortools.math_opt.python.solution import DualSolution +from ortools.math_opt.python.solution import parse_basis +from ortools.math_opt.python.solution import parse_dual_ray +from ortools.math_opt.python.solution import parse_dual_solution +from ortools.math_opt.python.solution import parse_primal_ray +from ortools.math_opt.python.solution import parse_primal_solution +from ortools.math_opt.python.solution import parse_solution +from ortools.math_opt.python.solution import PrimalRay +from ortools.math_opt.python.solution import PrimalSolution +from ortools.math_opt.python.solution import Solution +from ortools.math_opt.python.solution import SolutionStatus +from ortools.math_opt.python.solve import compute_infeasible_subsystem +from ortools.math_opt.python.solve import IncrementalSolver +from ortools.math_opt.python.solve import solve +from ortools.math_opt.python.solve import SolveCallback +from ortools.math_opt.python.sparse_containers import LinearConstraintFilter +from ortools.math_opt.python.sparse_containers import parse_linear_constraint_map +from ortools.math_opt.python.sparse_containers import parse_variable_map +from ortools.math_opt.python.sparse_containers import SparseVectorFilter +from ortools.math_opt.python.sparse_containers import to_sparse_double_vector_proto +from ortools.math_opt.python.sparse_containers import to_sparse_int32_vector_proto +from ortools.math_opt.python.sparse_containers import VariableFilter +from ortools.math_opt.python.sparse_containers import VarOrConstraintType + +# pylint: enable=unused-import +# pylint: enable=g-importing-member diff --git a/ortools/math_opt/python/mathopt_test.py b/ortools/math_opt/python/mathopt_test.py new file mode 100644 index 0000000000..26e97f0f01 --- /dev/null +++ b/ortools/math_opt/python/mathopt_test.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for mathopt.""" +import inspect +import types +import typing +from typing import Any, List, Set, Tuple + +import unittest +from ortools.math_opt.python import callback +from ortools.math_opt.python import hash_model_storage +from ortools.math_opt.python import mathopt +from ortools.math_opt.python import message_callback +from ortools.math_opt.python import model +from ortools.math_opt.python import model_parameters +from ortools.math_opt.python import model_storage +from ortools.math_opt.python import parameters +from ortools.math_opt.python import result +from ortools.math_opt.python import solution +from ortools.math_opt.python import solve +from ortools.math_opt.python import sparse_containers + +# This list does not contain some modules intentionally: +# +# - `remote_solve`: this depends on Stubby and having it in mathopt.py would +# force all users using MathOpt to depend on Stubby. +# +# - `statistics`: this is not part of the main libraries. In C++ too it is not +# included by `cpp/math_opt.h`. If we decide to change that, then maybe it +# would make sense to replace the top-level functions by member functions on +# the Model. +# +_MODULES_TO_CHECK: List[types.ModuleType] = [ + callback, + hash_model_storage, + message_callback, + model, + model_parameters, + model_storage, + parameters, + result, + sparse_containers, + solution, + solve, +] + +# Some symbols are not meant to be exported; we exclude them here. +_EXCLUDED_SYMBOLS: Set[Tuple[types.ModuleType, str]] = { + (solution, "T"), +} + +_TYPING_PUBLIC_CONTENT = [ + getattr(typing, name) for name in dir(typing) if not name.startswith("_") +] + + +def _is_actual_export(v: Any) -> bool: + if inspect.ismodule(v): + return False + if getattr(v, "__module__", None) != typing.__name__: + return True + return v not in _TYPING_PUBLIC_CONTENT + + +def _get_public_api(module: types.ModuleType) -> List[Tuple[str, Any]]: + tuple_list = inspect.getmembers(module, _is_actual_export) + return [(name, obj) for name, obj in tuple_list if not name.startswith("_")] + + +class MathoptTest(unittest.TestCase): + def test_imports(self) -> None: + missing_imports: List[str] = [] + for module in _MODULES_TO_CHECK: + for name, obj in _get_public_api(module): + if (module, name) in _EXCLUDED_SYMBOLS: + continue + if hasattr(mathopt, name): + self.assertIs( + getattr(mathopt, name), + obj, + msg=f"module: {module.__name__} name: {name}", + ) + else: + # We don't immediately asserts on a missing import so that we can get + # the list of all missing ones. + missing_imports.append(f"from {module.__name__} import {name}") + # We can't have \ in an expression inside an f-string. + nl = "\n" + self.assertFalse( + bool(missing_imports), + msg=f"missing imports:\n{nl.join(missing_imports)}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/message_callback.py b/ortools/math_opt/python/message_callback.py new file mode 100644 index 0000000000..86d64047f4 --- /dev/null +++ b/ortools/math_opt/python/message_callback.py @@ -0,0 +1,142 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Definition and tools for message callbacks. + +Message callbacks are used to get the text messages emitted by solvers during +the solve. + + Typical usage example: + + # Print messages to stdout. + result = solve.solve( + model, parameters.SolverType.GSCIP, + msg_cb=message_callback.printer_message_callback(prefix='[solver] ')) + + # Log messages with absl.logging. + result = solve.solve( + model, parameters.SolverType.GSCIP, + msg_cb=lambda msgs: message_callback.log_messages( + msgs, prefix='[solver] ')) +""" + +import sys +import threading +from typing import Callable, List, Sequence, TextIO + +from absl import logging + +SolveMessageCallback = Callable[[Sequence[str]], None] + + +def printer_message_callback( + *, file: TextIO = sys.stdout, prefix: str = "" +) -> SolveMessageCallback: + """Returns a message callback that prints to a file. + + It prints its output to the given text file, prefixing each line with the + given prefix. + + For each call to the returned message callback, the output_stream is flushed. + + Args: + file: The file to print to. It prints to stdout by default. + prefix: The prefix to print in front of each line. + + Returns: + A function matching the expected signature for message callbacks. + """ + mutex = threading.Lock() + + def callback(messages: Sequence[str]) -> None: + with mutex: + for message in messages: + file.write(prefix) + file.write(message) + file.write("\n") + file.flush() + + return callback + + +def log_messages( + messages: Sequence[str], *, level: int = logging.INFO, prefix: str = "" +) -> None: + """Logs the input messages from a message callback using absl.logging.log(). + + It logs each line with the given prefix. It setups absl.logging so that the + logs use the file name and line of the caller of this function. + + Typical usage example: + + result = solve.solve( + model, parameters.SolverType.GSCIP, + msg_cb=lambda msgs: message_callback.log_messages( + msgs, prefix='[solver] ')) + + Args: + messages: The messages received in the message callback (typically a lambda + function in the caller code). + level: One of absl.logging.(DEBUG|INFO|WARNING|ERROR|FATAL). + prefix: The prefix to print in front of each line. + """ + for message in messages: + logging.log(level, "%s%s", prefix, message) + + +logging.ABSLLogger.register_frame_to_skip(__file__, log_messages.__name__) + + +def vlog_messages(messages: Sequence[str], level: int, *, prefix: str = "") -> None: + """Logs the input messages from a message callback using absl.logging.vlog(). + + It logs each line with the given prefix. It setups absl.logging so that the + logs use the file name and line of the caller of this function. + + Typical usage example: + + result = solve.solve( + model, parameters.SolverType.GSCIP, + msg_cb=lambda msgs: message_callback.vlog_messages( + msgs, 1, prefix='[solver] ')) + + Args: + messages: The messages received in the message callback (typically a lambda + function in the caller code). + level: The verbose log level, e.g. 1, 2... + prefix: The prefix to print in front of each line. + """ + for message in messages: + logging.vlog(level, "%s%s", prefix, message) + + +logging.ABSLLogger.register_frame_to_skip(__file__, vlog_messages.__name__) + + +def list_message_callback(sink: List[str]) -> SolveMessageCallback: + """Returns a message callback that logs messages to a list. + + Args: + sink: The list to append messages to. + + Returns: + A function matching the expected signature for message callbacks. + """ + mutex = threading.Lock() + + def callback(messages: Sequence[str]) -> None: + with mutex: + for message in messages: + sink.append(message) + + return callback diff --git a/ortools/math_opt/python/message_callback_test.py b/ortools/math_opt/python/message_callback_test.py new file mode 100644 index 0000000000..d8fdcd12c4 --- /dev/null +++ b/ortools/math_opt/python/message_callback_test.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for message_callback.""" + +import io +import os + +from absl import logging + +import unittest +from ortools.math_opt.python import message_callback + + +class PrinterMessageCallbackTest(unittest.TestCase): + def test_no_prefix(self): + class FlushCountingStringIO(io.StringIO): + def __init__(self): + super().__init__() + self.num_flushes: int = 0 + + def flush(self): + super().flush() + self.num_flushes += 1 + + buf = FlushCountingStringIO() + cb = message_callback.printer_message_callback(file=buf) + cb(["line 1", "line 2"]) + cb(["line 3"]) + + self.assertMultiLineEqual(buf.getvalue(), "line 1\nline 2\nline 3\n") + self.assertEqual(buf.num_flushes, 2) + + def test_with_prefix(self): + buf = io.StringIO() + cb = message_callback.printer_message_callback(file=buf, prefix="test> ") + cb(["line 1", "line 2"]) + cb(["line 3"]) + + self.assertMultiLineEqual( + buf.getvalue(), "test> line 1\ntest> line 2\ntest> line 3\n" + ) + + +class LogMessagesTest(unittest.TestCase): + def test_defaults(self): + with self.assertLogs(logger="absl", level="INFO") as logs: + message_callback.log_messages(["line 1", "line 2"]) + self.assertListEqual(logs.output, ["INFO:absl:line 1", "INFO:absl:line 2"]) + + def test_prefix(self): + with self.assertLogs(logger="absl") as logs: + message_callback.log_messages(["line 1", "line 2"], prefix="solver: ") + self.assertListEqual( + logs.output, ["INFO:absl:solver: line 1", "INFO:absl:solver: line 2"] + ) + + def test_warning(self): + with self.assertLogs(logger="absl") as logs: + message_callback.log_messages(["line 1", "line 2"], level=logging.WARNING) + self.assertListEqual( + logs.output, ["WARNING:absl:line 1", "WARNING:absl:line 2"] + ) + + def test_records_path(self): + with self.assertLogs(logger="absl") as logs: + message_callback.log_messages(["line 1", "line 2"]) + self.assertSetEqual( + set(os.path.basename(r.pathname) for r in logs.records), + set(("message_callback_test.py",)), + ) + + +class VLogMessagesTest(unittest.TestCase): + """Tests of vlog_messages(). + + In the tests we abuse the logging level 0 since there is not API in the + `logging` module to change the verbosity. + """ + + def test_defaults(self): + with self.assertLogs(logger="absl") as logs: + message_callback.vlog_messages(["line 1", "line 2"], 0) + self.assertListEqual(logs.output, ["INFO:absl:line 1", "INFO:absl:line 2"]) + + def test_prefix(self): + with self.assertLogs(logger="absl") as logs: + message_callback.vlog_messages(["line 1", "line 2"], 0, prefix="solver: ") + self.assertListEqual( + logs.output, ["INFO:absl:solver: line 1", "INFO:absl:solver: line 2"] + ) + + def test_records_path(self): + with self.assertLogs(logger="absl") as logs: + message_callback.vlog_messages(["line 1", "line 2"], 0) + self.assertSetEqual( + set(os.path.basename(r.pathname) for r in logs.records), + set(("message_callback_test.py",)), + ) + + +class ListMessageCallbackTest(unittest.TestCase): + def test_empty(self): + msgs = [] + cb = message_callback.list_message_callback(msgs) + cb(["line 1", "line 2"]) + cb(["line 3"]) + + self.assertSequenceEqual(msgs, ("line 1", "line 2", "line 3")) + + def test_not_empty(self): + msgs = ["initial", "content"] + cb = message_callback.list_message_callback(msgs) + cb(["line 1", "line 2"]) + cb(["line 3"]) + + self.assertSequenceEqual( + msgs, ("initial", "content", "line 1", "line 2", "line 3") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/model.py b/ortools/math_opt/python/model.py new file mode 100644 index 0000000000..98592fcbdc --- /dev/null +++ b/ortools/math_opt/python/model.py @@ -0,0 +1,2253 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A solver independent library for modeling optimization problems. + +Example use to model the optimization problem: + max 2.0 * x + y + s.t. x + y <= 1.5 + x in {0.0, 1.0} + y in [0.0, 2.5] + + model = mathopt.Model(name='my_model') + x = model.add_binary_variable(name='x') + y = model.add_variable(lb=0.0, ub=2.5, name='y') + # We can directly use linear combinations of variables ... + model.add_linear_constraint(x + y <= 1.5, name='c') + # ... or build them incrementally. + objective_expression = 0 + objective_expression += 2 * x + objective_expression += y + model.maximize(objective_expression) + + # May raise a RuntimeError on invalid input or internal solver errors. + result = mathopt.solve(model, mathopt.SolverType.GSCIP) + + if result.termination.reason not in (mathopt.TerminationReason.OPTIMAL, + mathopt.TerminationReason.FEASIBLE): + raise RuntimeError(f'model failed to solve: {result.termination}') + + print(f'Objective value: {result.objective_value()}') + print(f'Value for variable x: {result.variable_values()[x]}') +""" + +import abc +import collections +import dataclasses +import math +import typing +from typing import ( + Any, + DefaultDict, + Deque, + Generic, + Iterable, + Iterator, + Mapping, + NamedTuple, + NoReturn, + Optional, + Protocol, + Tuple, + Type, + TypeVar, + Union, +) +import weakref + +import immutabledict + +from ortools.math_opt import model_pb2 +from ortools.math_opt import model_update_pb2 +from ortools.math_opt.python import hash_model_storage +from ortools.math_opt.python import model_storage + +Storage = model_storage.ModelStorage +StorageClass = model_storage.ModelStorageImplClass + +LinearTypes = Union[int, float, "LinearBase"] +QuadraticTypes = Union[int, float, "LinearBase", "QuadraticBase"] +LinearTypesExceptVariable = Union[ + float, int, "LinearTerm", "LinearExpression", "LinearSum", "LinearProduct" +] + +_CHAINED_COMPARISON_MESSAGE = ( + "If you were trying to create a two-sided or " + "ranged linear inequality of the form `lb <= " + "expr <= ub`, try `(lb <= expr) <= ub` instead" +) +_EXPRESSION_COMP_EXPRESSION_MESSAGE = ( + "This error can occur when adding " + "inequalities of the form `(a <= b) <= " + "c` where (a, b, c) includes two or more" + " non-constant linear expressions" +) + + +def _raise_binary_operator_type_error( + operator: str, + lhs: Type[Any], + rhs: Type[Any], + extra_message: Optional[str] = None, +) -> NoReturn: + """Raises TypeError on unsupported operators.""" + message = ( + f"unsupported operand type(s) for {operator}: {lhs.__name__!r} and" + f" {rhs.__name__!r}" + ) + if extra_message is not None: + message += "\n" + extra_message + raise TypeError(message) + + +def _raise_ne_not_supported() -> NoReturn: + raise TypeError("!= constraints are not supported") + + +class UpperBoundedLinearExpression: + """An inequality of the form expression <= upper_bound. + + Where: + * expression is a linear expression, and + * upper_bound is a float + """ + + __slots__ = "_expression", "_upper_bound" + + def __init__(self, expression: "LinearBase", upper_bound: float) -> None: + """Operator overloading can be used instead: e.g. `x + y <= 2.0`.""" + self._expression: "LinearBase" = expression + self._upper_bound: float = upper_bound + + @property + def expression(self) -> "LinearBase": + return self._expression + + @property + def upper_bound(self) -> float: + return self._upper_bound + + def __ge__(self, lhs: float) -> "BoundedLinearExpression": + if isinstance(lhs, (int, float)): + return BoundedLinearExpression(lhs, self.expression, self.upper_bound) + _raise_binary_operator_type_error(">=", type(self), type(lhs)) + + def __bool__(self) -> bool: + raise TypeError( + "__bool__ is unsupported for UpperBoundedLinearExpression" + + "\n" + + _CHAINED_COMPARISON_MESSAGE + ) + + def __str__(self): + return f"{self._expression!s} <= {self._upper_bound}" + + def __repr__(self): + return f"{self._expression!r} <= {self._upper_bound}" + + +class LowerBoundedLinearExpression: + """An inequality of the form expression >= lower_bound. + + Where: + * expression is a linear expression, and + * lower_bound is a float + """ + + __slots__ = "_expression", "_lower_bound" + + def __init__(self, expression: "LinearBase", lower_bound: float) -> None: + """Operator overloading can be used instead: e.g. `x + y >= 2.0`.""" + self._expression: "LinearBase" = expression + self._lower_bound: float = lower_bound + + @property + def expression(self) -> "LinearBase": + return self._expression + + @property + def lower_bound(self) -> float: + return self._lower_bound + + def __le__(self, rhs: float) -> "BoundedLinearExpression": + if isinstance(rhs, (int, float)): + return BoundedLinearExpression(self.lower_bound, self.expression, rhs) + _raise_binary_operator_type_error("<=", type(self), type(rhs)) + + def __bool__(self) -> bool: + raise TypeError( + "__bool__ is unsupported for LowerBoundedLinearExpression" + + "\n" + + _CHAINED_COMPARISON_MESSAGE + ) + + def __str__(self): + return f"{self._expression!s} >= {self._lower_bound}" + + def __repr__(self): + return f"{self._expression!r} >= {self._lower_bound}" + + +class BoundedLinearExpression: + """An inequality of the form lower_bound <= expression <= upper_bound. + + Where: + * expression is a linear expression + * lower_bound is a float, and + * upper_bound is a float + + Note: Because of limitations related to Python's handling of chained + comparisons, bounded expressions cannot be directly created usign + overloaded comparisons as in `lower_bound <= expression <= upper_bound`. + One solution is to wrap one of the inequalities in parenthesis as in + `(lower_bound <= expression) <= upper_bound`. + """ + + __slots__ = "_expression", "_lower_bound", "_upper_bound" + + def __init__( + self, lower_bound: float, expression: "LinearBase", upper_bound: float + ) -> None: + self._expression: "LinearBase" = expression + self._lower_bound: float = lower_bound + self._upper_bound: float = upper_bound + + @property + def expression(self) -> "LinearBase": + return self._expression + + @property + def lower_bound(self) -> float: + return self._lower_bound + + @property + def upper_bound(self) -> float: + return self._upper_bound + + def __bool__(self) -> bool: + raise TypeError( + "__bool__ is unsupported for BoundedLinearExpression" + + "\n" + + _CHAINED_COMPARISON_MESSAGE + ) + + def __str__(self): + return f"{self._lower_bound} <= {self._expression!s} <= {self._upper_bound}" + + def __repr__(self): + return f"{self._lower_bound} <= {self._expression!r} <= {self._upper_bound}" + + +class VarEqVar: + """The result of the equality comparison between two Variable. + + We use an object here to delay the evaluation of equality so that we can use + the operator== in two use-cases: + + 1. when the user want to test that two Variable values references the same + variable. This is supported by having this object support implicit + conversion to bool. + + 2. when the user want to use the equality to create a constraint of equality + between two variables. + """ + + __slots__ = "_first_variable", "_second_variable" + + def __init__( + self, + first_variable: "Variable", + second_variable: "Variable", + ) -> None: + self._first_variable: "Variable" = first_variable + self._second_variable: "Variable" = second_variable + + @property + def first_variable(self) -> "Variable": + return self._first_variable + + @property + def second_variable(self) -> "Variable": + return self._second_variable + + def __bool__(self) -> bool: + return ( + self._first_variable.model is self._second_variable.model + and self._first_variable.id == self._second_variable.id + ) + + def __str__(self): + return f"{self.first_variable!s} == {self._second_variable!s}" + + def __repr__(self): + return f"{self.first_variable!r} == {self._second_variable!r}" + + +BoundedLinearTypesList = ( + LowerBoundedLinearExpression, + UpperBoundedLinearExpression, + BoundedLinearExpression, + VarEqVar, +) +BoundedLinearTypes = Union[BoundedLinearTypesList] + + +# TODO(b/231426528): consider using a frozen dataclass. +class QuadraticTermKey: + """An id-ordered pair of variables used as a key for quadratic terms.""" + + __slots__ = "_first_var", "_second_var" + + def __init__(self, a: "Variable", b: "Variable"): + """Variables a and b will be ordered internally by their ids.""" + self._first_var: "Variable" = a + self._second_var: "Variable" = b + if self._first_var.id > self._second_var.id: + self._first_var, self._second_var = self._second_var, self._first_var + + @property + def first_var(self) -> "Variable": + return self._first_var + + @property + def second_var(self) -> "Variable": + return self._second_var + + def __eq__(self, other: "QuadraticTermKey") -> bool: + return bool( + self._first_var == other._first_var + and self._second_var == other._second_var + ) + + def __hash__(self) -> int: + return hash((self._first_var, self._second_var)) + + def __str__(self): + return f"{self._first_var!s} * {self._second_var!s}" + + def __repr__(self): + return f"QuadraticTermKey({self._first_var!r}, {self._second_var!r})" + + +@dataclasses.dataclass +class _ProcessedElements: + """Auxiliary data class for LinearBase._flatten_once_and_add_to().""" + + terms: DefaultDict["Variable", float] = dataclasses.field( + default_factory=lambda: collections.defaultdict(float) + ) + offset: float = 0.0 + + +@dataclasses.dataclass +class _QuadraticProcessedElements(_ProcessedElements): + """Auxiliary data class for QuadraticBase._quadratic_flatten_once_and_add_to().""" + + quadratic_terms: DefaultDict["QuadraticTermKey", float] = dataclasses.field( + default_factory=lambda: collections.defaultdict(float) + ) + + +class _ToProcessElements(Protocol): + """Auxiliary to-process stack interface for LinearBase._flatten_once_and_add_to() and QuadraticBase._quadratic_flatten_once_and_add_to().""" + + __slots__ = () + + def append(self, term: "LinearBase", scale: float) -> None: + """Add a linear object and scale to the to-process stack.""" + + +_T = TypeVar("_T", "LinearBase", Union["LinearBase", "QuadraticBase"]) + + +class _ToProcessElementsImplementation(Generic[_T]): + """Auxiliary data class for LinearBase._flatten_once_and_add_to().""" + + __slots__ = ("_queue",) + + def __init__(self, term: _T, scale: float) -> None: + self._queue: Deque[Tuple[_T, float]] = collections.deque([(term, scale)]) + + def append(self, term: _T, scale: float) -> None: + self._queue.append((term, scale)) + + def pop(self) -> Tuple[_T, float]: + return self._queue.popleft() + + def __bool__(self) -> bool: + return bool(self._queue) + + +_LinearToProcessElements = _ToProcessElementsImplementation["LinearBase"] +_QuadraticToProcessElements = _ToProcessElementsImplementation[ + Union["LinearBase", "QuadraticBase"] +] + + +class LinearBase(metaclass=abc.ABCMeta): + """Interface for types that can build linear expressions with +, -, * and /. + + Classes derived from LinearBase (plus float and int scalars) are used to + build expression trees describing a linear expression. Operations nodes of the + expression tree include: + + * LinearSum: describes a deferred sum of LinearTypes objects. + * LinearProduct: describes a deferred product of a scalar and a + LinearTypes object. + + Leaf nodes of the expression tree include: + + * float and int scalars. + * Variable: a single variable. + * LinearTerm: the product of a scalar and a Variable object. + * LinearExpression: the sum of a scalar and LinearTerm objects. + + LinearBase objects/expression-trees can be used directly to create + constraints or objective functions. However, to facilitate their inspection, + any LinearTypes object can be flattened to a LinearExpression + through: + + as_flat_linear_expression(value: LinearTypes) -> LinearExpression: + + In addition, all LinearBase objects are immutable. + + Performance notes: + + Using an expression tree representation instead of an eager construction of + LinearExpression objects reduces known inefficiencies associated with the + use of operator overloading to construct linear expressions. In particular, we + expect the runtime of as_flat_linear_expression() to be linear in the size of + the expression tree. Additional performance can gained by using LinearSum(c) + instead of sum(c) for a container c, as the latter creates len(c) LinearSum + objects. + """ + + __slots__ = () + + # TODO(b/216492143): explore requirements for this function so calculation of + # coefficients and offsets follow expected associativity rules (so approximate + # float calculations are as expected). + # TODO(b/216492143): add more details of what subclasses need to do in + # developers guide. + @abc.abstractmethod + def _flatten_once_and_add_to( + self, + scale: float, + processed_elements: _ProcessedElements, + target_stack: _ToProcessElements, + ) -> None: + """Flatten one level of tree if needed and add to targets. + + Classes derived from LinearBase only need to implement this function + to enable transformation to LinearExpression through + as_flat_linear_expression(). + + Args: + scale: multiply elements by this number when processing or adding to + stack. + processed_elements: where to add LinearTerms and scalars that can be + processed immediately. + target_stack: where to add LinearBase elements that are not scalars or + LinearTerms (i.e. elements that need further flattening). + Implementations should append() to this stack to avoid being recursive. + """ + + def __eq__( + self, rhs: LinearTypes + ) -> ( + "BoundedLinearExpression" + ): # pytype: disable=signature-mismatch # overriding-return-type-checks + if isinstance(rhs, (int, float)): + return BoundedLinearExpression(rhs, self, rhs) + if not isinstance(rhs, LinearBase): + _raise_binary_operator_type_error("==", type(self), type(rhs)) + return BoundedLinearExpression(0.0, self - rhs, 0.0) + + def __ne__( + self, rhs: LinearTypes + ) -> ( + NoReturn + ): # pytype: disable=signature-mismatch # overriding-return-type-checks + _raise_ne_not_supported() + + @typing.overload + def __le__(self, rhs: float) -> "UpperBoundedLinearExpression": + ... + + @typing.overload + def __le__(self, rhs: "LinearBase") -> "BoundedLinearExpression": + ... + + @typing.overload + def __le__(self, rhs: "BoundedLinearExpression") -> NoReturn: + ... + + def __le__(self, rhs): + if isinstance(rhs, (int, float)): + return UpperBoundedLinearExpression(self, rhs) + if isinstance(rhs, LinearBase): + return BoundedLinearExpression(-math.inf, self - rhs, 0.0) + if isinstance(rhs, BoundedLinearExpression): + _raise_binary_operator_type_error( + "<=", type(self), type(rhs), _EXPRESSION_COMP_EXPRESSION_MESSAGE + ) + _raise_binary_operator_type_error("<=", type(self), type(rhs)) + + @typing.overload + def __ge__(self, lhs: float) -> "LowerBoundedLinearExpression": + ... + + @typing.overload + def __ge__(self, lhs: "LinearBase") -> "BoundedLinearExpression": + ... + + @typing.overload + def __ge__(self, lhs: "BoundedLinearExpression") -> NoReturn: + ... + + def __ge__(self, lhs): + if isinstance(lhs, (int, float)): + return LowerBoundedLinearExpression(self, lhs) + if isinstance(lhs, LinearBase): + return BoundedLinearExpression(0.0, self - lhs, math.inf) + if isinstance(lhs, BoundedLinearExpression): + _raise_binary_operator_type_error( + ">=", type(self), type(lhs), _EXPRESSION_COMP_EXPRESSION_MESSAGE + ) + _raise_binary_operator_type_error(">=", type(self), type(lhs)) + + def __add__(self, expr: LinearTypes) -> "LinearSum": + if not isinstance(expr, (int, float, LinearBase)): + return NotImplemented + return LinearSum((self, expr)) + + def __radd__(self, expr: LinearTypes) -> "LinearSum": + if not isinstance(expr, (int, float, LinearBase)): + return NotImplemented + return LinearSum((expr, self)) + + def __sub__(self, expr: LinearTypes) -> "LinearSum": + if not isinstance(expr, (int, float, LinearBase)): + return NotImplemented + return LinearSum((self, -expr)) + + def __rsub__(self, expr: LinearTypes) -> "LinearSum": + if not isinstance(expr, (int, float, LinearBase, QuadraticBase)): + return NotImplemented + return LinearSum((expr, -self)) + + @typing.overload + def __mul__(self, other: float) -> "LinearProduct": + ... + + @typing.overload + def __mul__(self, other: "LinearBase") -> "LinearLinearProduct": + ... + + def __mul__(self, other): + if not isinstance(other, (int, float, LinearBase)): + return NotImplemented + if isinstance(other, LinearBase): + return LinearLinearProduct(self, other) + return LinearProduct(other, self) + + def __rmul__(self, constant: float) -> "LinearProduct": + if not isinstance(constant, (int, float)): + return NotImplemented + return LinearProduct(constant, self) + + # TODO(b/216492143): explore numerical consequences of 1.0 / constant below. + def __truediv__(self, constant: float) -> "LinearProduct": + if not isinstance(constant, (int, float)): + return NotImplemented + return LinearProduct(1.0 / constant, self) + + def __neg__(self) -> "LinearProduct": + return LinearProduct(-1.0, self) + + +class QuadraticBase(metaclass=abc.ABCMeta): + """Interface for types that can build quadratic expressions with +, -, * and /. + + Classes derived from QuadraticBase and LinearBase (plus float and int scalars) + are used to build expression trees describing a quadratic expression. + Operations nodes of the expression tree include: + + * QuadraticSum: describes a deferred sum of QuadraticTypes objects. + * QuadraticProduct: describes a deferred product of a scalar and a + QuadraticTypes object. + * LinearLinearProduct: describes a deferred product of two LinearTypes + objects. + + Leaf nodes of the expression tree include: + + * float and int scalars. + * Variable: a single variable. + * LinearTerm: the product of a scalar and a Variable object. + * LinearExpression: the sum of a scalar and LinearTerm objects. + * QuadraticTerm: the product of a scalar and two Variable objects. + * QuadraticExpression: the sum of a scalar, LinearTerm objects and + QuadraticTerm objects. + + QuadraticBase objects/expression-trees can be used directly to create + objective functions. However, to facilitate their inspection, any + QuadraticTypes object can be flattened to a QuadraticExpression + through: + + as_flat_quadratic_expression(value: QuadraticTypes) -> QuadraticExpression: + + In addition, all QuadraticBase objects are immutable. + + Performance notes: + + Using an expression tree representation instead of an eager construction of + QuadraticExpression objects reduces known inefficiencies associated with the + use of operator overloading to construct quadratic expressions. In particular, + we expect the runtime of as_flat_quadratic_expression() to be linear in the + size of the expression tree. Additional performance can gained by using + QuadraticSum(c) instead of sum(c) for a container c, as the latter creates + len(c) QuadraticSum objects. + """ + + __slots__ = () + + # TODO(b/216492143): explore requirements for this function so calculation of + # coefficients and offsets follow expected associativity rules (so approximate + # float calculations are as expected). + # TODO(b/216492143): add more details of what subclasses need to do in + # developers guide. + @abc.abstractmethod + def _quadratic_flatten_once_and_add_to( + self, + scale: float, + processed_elements: _QuadraticProcessedElements, + target_stack: _QuadraticToProcessElements, + ) -> None: + """Flatten one level of tree if needed and add to targets. + + Classes derived from QuadraticBase only need to implement this function + to enable transformation to QuadraticExpression through + as_flat_quadratic_expression(). + + Args: + scale: multiply elements by this number when processing or adding to + stack. + processed_elements: where to add linear terms, quadratic terms and scalars + that can be processed immediately. + target_stack: where to add LinearBase and QuadraticBase elements that are + not scalars or linear terms or quadratic terms (i.e. elements that need + further flattening). Implementations should append() to this stack to + avoid being recursive. + """ + + def __add__(self, expr: QuadraticTypes) -> "QuadraticSum": + if not isinstance(expr, (int, float, LinearBase, QuadraticBase)): + return NotImplemented + return QuadraticSum((self, expr)) + + def __radd__(self, expr: QuadraticTypes) -> "QuadraticSum": + if not isinstance(expr, (int, float, LinearBase, QuadraticBase)): + return NotImplemented + return QuadraticSum((expr, self)) + + def __sub__(self, expr: QuadraticTypes) -> "QuadraticSum": + if not isinstance(expr, (int, float, LinearBase, QuadraticBase)): + return NotImplemented + return QuadraticSum((self, -expr)) + + def __rsub__(self, expr: QuadraticTypes) -> "QuadraticSum": + if not isinstance(expr, (int, float, LinearBase, QuadraticBase)): + return NotImplemented + return QuadraticSum((expr, -self)) + + def __mul__(self, other: float) -> "QuadraticProduct": + if not isinstance(other, (int, float)): + return NotImplemented + return QuadraticProduct(other, self) + + def __rmul__(self, other: float) -> "QuadraticProduct": + if not isinstance(other, (int, float)): + return NotImplemented + return QuadraticProduct(other, self) + + # TODO(b/216492143): explore numerical consequences of 1.0 / constant below. + def __truediv__(self, constant: float) -> "QuadraticProduct": + if not isinstance(constant, (int, float)): + return NotImplemented + return QuadraticProduct(1.0 / constant, self) + + def __neg__(self) -> "QuadraticProduct": + return QuadraticProduct(-1.0, self) + + +class Variable(LinearBase): + """A decision variable for an optimization model. + + A decision variable takes a value from a domain, either the real numbers or + the integers, and restricted to be in some interval [lb, ub] (where lb and ub + can be infinite). The case of lb == ub is allowed, this means the variable + must take a single value. The case of lb > ub is also allowed, this implies + that the problem is infeasible. + + A Variable is configured as follows: + * lower_bound: a float property, lb above. Should not be NaN nor +inf. + * upper_bound: a float property, ub above. Should not be NaN nor -inf. + * integer: a bool property, if the domain is integer or continuous. + + The name is optional, read only, and used only for debugging. Non-empty names + should be distinct. + + Every Variable is associated with a Model (defined below). Note that data + describing the variable (e.g. lower_bound) is owned by Model.storage, this + class is simply a reference to that data. Do not create a Variable directly, + use Model.add_variable() instead. + """ + + __slots__ = "__weakref__", "_model", "_id" + + def __init__(self, model: "Model", vid: int) -> None: + """Do not invoke directly, use Model.add_variable().""" + self._model: "Model" = model + self._id: int = vid + + @property + def lower_bound(self) -> float: + return self.model.storage.get_variable_lb(self._id) + + @lower_bound.setter + def lower_bound(self, value: float) -> None: + self.model.storage.set_variable_lb(self._id, value) + + @property + def upper_bound(self) -> float: + return self.model.storage.get_variable_ub(self._id) + + @upper_bound.setter + def upper_bound(self, value: float) -> None: + self.model.storage.set_variable_ub(self._id, value) + + @property + def integer(self) -> bool: + return self.model.storage.get_variable_is_integer(self._id) + + @integer.setter + def integer(self, value: bool) -> None: + self.model.storage.set_variable_is_integer(self._id, value) + + @property + def name(self) -> str: + return self.model.storage.get_variable_name(self._id) + + @property + def id(self) -> int: + return self._id + + @property + def model(self) -> "Model": + return self._model + + def __str__(self): + """Returns the name, or a string containing the id if the name is empty.""" + return self.name if self.name else f"variable_{self.id}" + + def __repr__(self): + return f"" + + @typing.overload + def __eq__(self, rhs: "Variable") -> "VarEqVar": + ... + + @typing.overload + def __eq__(self, rhs: LinearTypesExceptVariable) -> "BoundedLinearExpression": + ... + + def __eq__(self, rhs): + if isinstance(rhs, Variable): + return VarEqVar(self, rhs) + return super().__eq__(rhs) + + @typing.overload + def __ne__(self, rhs: "Variable") -> bool: + ... + + @typing.overload + def __ne__(self, rhs: LinearTypesExceptVariable) -> NoReturn: + ... + + def __ne__(self, rhs): + if isinstance(rhs, Variable): + return not self == rhs + _raise_ne_not_supported() + + def __hash__(self) -> int: + return hash((self.model, self.id)) + + @typing.overload + def __mul__(self, other: float) -> "LinearTerm": + ... + + @typing.overload + def __mul__(self, other: Union["Variable", "LinearTerm"]) -> "QuadraticTerm": + ... + + @typing.overload + def __mul__(self, other: "LinearBase") -> "LinearLinearProduct": + ... + + def __mul__(self, other): + if not isinstance(other, (int, float, LinearBase)): + return NotImplemented + if isinstance(other, Variable): + return QuadraticTerm(QuadraticTermKey(self, other), 1.0) + if isinstance(other, LinearTerm): + return QuadraticTerm( + QuadraticTermKey(self, other.variable), other.coefficient + ) + if isinstance(other, LinearBase): + return LinearLinearProduct(self, other) # pytype: disable=bad-return-type + return LinearTerm(self, other) + + def __rmul__(self, constant: float) -> "LinearTerm": + if not isinstance(constant, (int, float)): + return NotImplemented + return LinearTerm(self, constant) + + # TODO(b/216492143): explore numerical consequences of 1.0 / constant below. + def __truediv__(self, constant: float) -> "LinearTerm": + if not isinstance(constant, (int, float)): + return NotImplemented + return LinearTerm(self, 1.0 / constant) + + def __neg__(self) -> "LinearTerm": + return LinearTerm(self, -1.0) + + def _flatten_once_and_add_to( + self, + scale: float, + processed_elements: _ProcessedElements, + target_stack: _ToProcessElements, + ) -> None: + processed_elements.terms[self] += scale + + +class LinearTerm(LinearBase): + """The product of a scalar and a variable. + + This class is immutable. + """ + + __slots__ = "_variable", "_coefficient" + + def __init__(self, variable: Variable, coefficient: float) -> None: + self._variable: Variable = variable + self._coefficient: float = coefficient + + @property + def variable(self) -> Variable: + return self._variable + + @property + def coefficient(self) -> float: + return self._coefficient + + def _flatten_once_and_add_to( + self, + scale: float, + processed_elements: _ProcessedElements, + target_stack: _ToProcessElements, + ) -> None: + processed_elements.terms[self._variable] += self._coefficient * scale + + @typing.overload + def __mul__(self, other: float) -> "LinearTerm": + ... + + @typing.overload + def __mul__(self, other: Union["Variable", "LinearTerm"]) -> "QuadraticTerm": + ... + + @typing.overload + def __mul__(self, other: "LinearBase") -> "LinearLinearProduct": + ... + + def __mul__(self, other): + if not isinstance(other, (int, float, LinearBase)): + return NotImplemented + if isinstance(other, Variable): + return QuadraticTerm( + QuadraticTermKey(self._variable, other), self._coefficient + ) + if isinstance(other, LinearTerm): + return QuadraticTerm( + QuadraticTermKey(self.variable, other.variable), + self._coefficient * other.coefficient, + ) + if isinstance(other, LinearBase): + return LinearLinearProduct(self, other) # pytype: disable=bad-return-type + return LinearTerm(self._variable, self._coefficient * other) + + def __rmul__(self, constant: float) -> "LinearTerm": + if not isinstance(constant, (int, float)): + return NotImplemented + return LinearTerm(self._variable, self._coefficient * constant) + + def __truediv__(self, constant: float) -> "LinearTerm": + if not isinstance(constant, (int, float)): + return NotImplemented + return LinearTerm(self._variable, self._coefficient / constant) + + def __neg__(self) -> "LinearTerm": + return LinearTerm(self._variable, -self._coefficient) + + def __str__(self): + return f"{self._coefficient} * {self._variable}" + + def __repr__(self): + return f"LinearTerm({self._variable!r}, {self._coefficient!r})" + + +class QuadraticTerm(QuadraticBase): + """The product of a scalar and two variables. + + This class is immutable. + """ + + __slots__ = "_key", "_coefficient" + + def __init__(self, key: QuadraticTermKey, coefficient: float) -> None: + self._key: QuadraticTermKey = key + self._coefficient: float = coefficient + + @property + def key(self) -> QuadraticTermKey: + return self._key + + @property + def coefficient(self) -> float: + return self._coefficient + + def _quadratic_flatten_once_and_add_to( + self, + scale: float, + processed_elements: _QuadraticProcessedElements, + target_stack: _ToProcessElements, + ) -> None: + processed_elements.quadratic_terms[self._key] += self._coefficient * scale + + def __mul__(self, constant: float) -> "QuadraticTerm": + if not isinstance(constant, (int, float)): + return NotImplemented + return QuadraticTerm(self._key, self._coefficient * constant) + + def __rmul__(self, constant: float) -> "QuadraticTerm": + if not isinstance(constant, (int, float)): + return NotImplemented + return QuadraticTerm(self._key, self._coefficient * constant) + + def __truediv__(self, constant: float) -> "QuadraticTerm": + if not isinstance(constant, (int, float)): + return NotImplemented + return QuadraticTerm(self._key, self._coefficient / constant) + + def __neg__(self) -> "QuadraticTerm": + return QuadraticTerm(self._key, -self._coefficient) + + def __str__(self): + return f"{self._coefficient} * {self._key!s}" + + def __repr__(self): + return f"QuadraticTerm({self._key!r}, {self._coefficient})" + + +class LinearExpression(LinearBase): + """For variables x, an expression: b + sum_{i in I} a_i * x_i. + + This class is immutable. + """ + + __slots__ = "__weakref__", "_terms", "_offset" + + # TODO(b/216492143): consider initializing from a dictionary. + def __init__(self, /, other: LinearTypes = 0) -> None: + self._offset: float = 0.0 + if isinstance(other, (int, float)): + self._offset = float(other) + self._terms: Mapping[Variable, float] = immutabledict.immutabledict() + return + + to_process: _LinearToProcessElements = _LinearToProcessElements(other, 1.0) + processed_elements = _ProcessedElements() + while to_process: + linear, coef = to_process.pop() + linear._flatten_once_and_add_to(coef, processed_elements, to_process) + # TODO(b/216492143): explore avoiding this copy. + self._terms: Mapping[Variable, float] = immutabledict.immutabledict( + processed_elements.terms + ) + self._offset = processed_elements.offset + + @property + def terms(self) -> Mapping[Variable, float]: + return self._terms + + @property + def offset(self) -> float: + return self._offset + + def _flatten_once_and_add_to( + self, + scale: float, + processed_elements: _ProcessedElements, + target_stack: _ToProcessElements, + ) -> None: + for var, val in self._terms.items(): + processed_elements.terms[var] += val * scale + processed_elements.offset += scale * self.offset + + # TODO(b/216492143): change __str__ to match C++ implementation in + # cl/421649402. + def __str__(self): + """Returns the name, or a string containing the id if the name is empty.""" + result = str(self.offset) + sorted_keys = sorted(self._terms.keys(), key=str) + for variable in sorted_keys: + # TODO(b/216492143): consider how to better deal with `NaN` and try to + # match C++ implementation in cl/421649402. See TODO for StrAndReprTest in + # linear_expression_test.py. + coefficient = self._terms[variable] + if coefficient == 0.0: + continue + if coefficient > 0: + result += " + " + else: + result += " - " + result += str(abs(coefficient)) + " * " + str(variable) + return result + + def __repr__(self): + result = f"LinearExpression({self.offset}, " + "{" + result += ", ".join( + f"{variable!r}: {coefficient}" + for variable, coefficient in self._terms.items() + ) + result += "})" + return result + + +class QuadraticExpression(QuadraticBase): + """For variables x, an expression: b + sum_{i in I} a_i * x_i + sum_{i,j in I, i<=j} a_i,j * x_i * x_j. + + This class is immutable. + """ + + __slots__ = "__weakref__", "_linear_terms", "_quadratic_terms", "_offset" + + # TODO(b/216492143): consider initializing from a dictionary. + def __init__(self, other: QuadraticTypes) -> None: + self._offset: float = 0.0 + if isinstance(other, (int, float)): + self._offset = float(other) + self._linear_terms: Mapping[Variable, float] = immutabledict.immutabledict() + self._quadratic_terms: Mapping[ + QuadraticTermKey, float + ] = immutabledict.immutabledict() + return + + to_process: _QuadraticToProcessElements = _QuadraticToProcessElements( + other, 1.0 + ) + processed_elements = _QuadraticProcessedElements() + while to_process: + linear_or_quadratic, coef = to_process.pop() + if isinstance(linear_or_quadratic, LinearBase): + linear_or_quadratic._flatten_once_and_add_to( + coef, processed_elements, to_process + ) + else: + linear_or_quadratic._quadratic_flatten_once_and_add_to( + coef, processed_elements, to_process + ) + # TODO(b/216492143): explore avoiding this copy. + self._linear_terms: Mapping[Variable, float] = immutabledict.immutabledict( + processed_elements.terms + ) + self._quadratic_terms: Mapping[ + QuadraticTermKey, float + ] = immutabledict.immutabledict(processed_elements.quadratic_terms) + self._offset = processed_elements.offset + + @property + def linear_terms(self) -> Mapping[Variable, float]: + return self._linear_terms + + @property + def quadratic_terms(self) -> Mapping[QuadraticTermKey, float]: + return self._quadratic_terms + + @property + def offset(self) -> float: + return self._offset + + def _quadratic_flatten_once_and_add_to( + self, + scale: float, + processed_elements: _QuadraticProcessedElements, + target_stack: _QuadraticToProcessElements, + ) -> None: + for var, val in self._linear_terms.items(): + processed_elements.terms[var] += val * scale + for key, val in self._quadratic_terms.items(): + processed_elements.quadratic_terms[key] += val * scale + processed_elements.offset += scale * self.offset + + # TODO(b/216492143): change __str__ to match C++ implementation in + # cl/421649402. + def __str__(self): + result = str(self.offset) + sorted_linear_keys = sorted(self._linear_terms.keys(), key=str) + for variable in sorted_linear_keys: + # TODO(b/216492143): consider how to better deal with `NaN` and try to + # match C++ implementation in cl/421649402. See TODO for StrAndReprTest in + # linear_expression_test.py. + coefficient = self._linear_terms[variable] + if coefficient == 0.0: + continue + if coefficient > 0: + result += " + " + else: + result += " - " + result += str(abs(coefficient)) + " * " + str(variable) + sorted_quadratic_keys = sorted(self._quadratic_terms.keys(), key=str) + for key in sorted_quadratic_keys: + # TODO(b/216492143): consider how to better deal with `NaN` and try to + # match C++ implementation in cl/421649402. See TODO for StrAndReprTest in + # linear_expression_test.py. + coefficient = self._quadratic_terms[key] + if coefficient == 0.0: + continue + if coefficient > 0: + result += " + " + else: + result += " - " + result += str(abs(coefficient)) + " * " + str(key) + return result + + def __repr__(self): + result = f"QuadraticExpression({self.offset}, " + "{" + result += ", ".join( + f"{variable!r}: {coefficient}" + for variable, coefficient in self._linear_terms.items() + ) + result += "}, {" + result += ", ".join( + f"{key!r}: {coefficient}" + for key, coefficient in self._quadratic_terms.items() + ) + result += "})" + return result + + +class LinearConstraint: + """A linear constraint for an optimization model. + + A LinearConstraint adds the following restriction on feasible solutions to an + optimization model: + lb <= sum_{i in I} a_i * x_i <= ub + where x_i are the decision variables of the problem. lb == ub is allowed, this + models the equality constraint: + sum_{i in I} a_i * x_i == b + lb > ub is also allowed, but the optimization problem will be infeasible. + + A LinearConstraint can be configured as follows: + * lower_bound: a float property, lb above. Should not be NaN nor +inf. + * upper_bound: a float property, ub above. Should not be NaN nor -inf. + * set_coefficient() and get_coefficient(): get and set the a_i * x_i + terms. The variable must be from the same model as this constraint, and + the a_i must be finite and not NaN. The coefficient for any variable not + set is 0.0, and setting a coefficient to 0.0 removes it from I above. + + The name is optional, read only, and used only for debugging. Non-empty names + should be distinct. + + Every LinearConstraint is associated with a Model (defined below). Note that + data describing the linear constraint (e.g. lower_bound) is owned by + Model.storage, this class is simply a reference to that data. Do not create a + LinearConstraint directly, use Model.add_linear_constraint() instead. + """ + + __slots__ = "__weakref__", "_model", "_id" + + def __init__(self, model: "Model", cid: int) -> None: + """Do not invoke directly, use Model.add_linear_constraint().""" + self._model: "Model" = model + self._id: int = cid + + @property + def lower_bound(self) -> float: + return self.model.storage.get_linear_constraint_lb(self._id) + + @lower_bound.setter + def lower_bound(self, value: float) -> None: + self.model.storage.set_linear_constraint_lb(self._id, value) + + @property + def upper_bound(self) -> float: + return self.model.storage.get_linear_constraint_ub(self._id) + + @upper_bound.setter + def upper_bound(self, value: float) -> None: + self.model.storage.set_linear_constraint_ub(self._id, value) + + @property + def name(self) -> str: + return self.model.storage.get_linear_constraint_name(self._id) + + @property + def id(self) -> int: + return self._id + + @property + def model(self) -> "Model": + return self._model + + def set_coefficient(self, variable: Variable, coefficient: float) -> None: + self.model.check_compatible(variable) + self.model.storage.set_linear_constraint_coefficient( + self._id, variable.id, coefficient + ) + + def get_coefficient(self, variable: Variable) -> float: + self.model.check_compatible(variable) + return self.model.storage.get_linear_constraint_coefficient( + self._id, variable.id + ) + + def terms(self) -> Iterator[LinearTerm]: + """Yields the variable/coefficient pairs with nonzero coefficient for this linear constraint.""" + for variable in self.model.row_nonzeros(self): + yield LinearTerm( + variable=variable, + coefficient=self.model.storage.get_linear_constraint_coefficient( + self._id, variable.id + ), + ) + + def __str__(self): + """Returns the name, or a string containing the id if the name is empty.""" + return self.name if self.name else f"linear_constraint_{self.id}" + + def __repr__(self): + return f"" + + +class Objective: + """The objective for an optimization model. + + An objective is either of the form: + min o + sum_{i in I} c_i * x_i + sum_{i, j in I, i <= j} q_i,j * x_i * x_j + or + max o + sum_{i in I} c_i * x_i + sum_{(i, j) in Q} q_i,j * x_i * x_j + where x_i are the decision variables of the problem and where all pairs (i, j) + in Q satisfy i <= j. The values of o, c_i and q_i,j should be finite and not + NaN. + + The objective can be configured as follows: + * offset: a float property, o above. Should be finite and not NaN. + * is_maximize: a bool property, if the objective is to maximize or minimize. + * set_linear_coefficient and get_linear_coefficient control the c_i * x_i + terms. The variables must be from the same model as this objective, and + the c_i must be finite and not NaN. The coefficient for any variable not + set is 0.0, and setting a coefficient to 0.0 removes it from I above. + * set_quadratic_coefficient and get_quadratic_coefficient control the + q_i,j * x_i * x_j terms. The variables must be from the same model as this + objective, and the q_i,j must be finite and not NaN. The coefficient for + any pair of variables not set is 0.0, and setting a coefficient to 0.0 + removes the associated (i,j) from Q above. + + Every Objective is associated with a Model (defined below). Note that data + describing the objective (e.g. offset) is owned by Model.storage, this class + is simply a reference to that data. Do not create an Objective directly, + use Model.objective to access the objective instead. + + The objective will be linear if only linear coefficients are set. This can be + useful to avoid solve-time errors with solvers that do not accept quadratic + objectives. To facilitate this linear objective guarantee we provide three + functions to add to the objective: + * add(), which accepts linear or quadratic expressions, + * add_quadratic(), which also accepts linear or quadratic expressions and + can be used to signal a quadratic objective is possible, and + * add_linear(), which only accepts linear expressions and can be used to + guarantee the objective remains linear. + """ + + __slots__ = ("_model",) + + def __init__(self, model: "Model") -> None: + """Do no invoke directly, use Model.objective.""" + self._model: "Model" = model + + @property + def is_maximize(self) -> bool: + return self.model.storage.get_is_maximize() + + @is_maximize.setter + def is_maximize(self, is_maximize: bool) -> None: + self.model.storage.set_is_maximize(is_maximize) + + @property + def offset(self) -> float: + return self.model.storage.get_objective_offset() + + @offset.setter + def offset(self, value: float) -> None: + self.model.storage.set_objective_offset(value) + + @property + def model(self) -> "Model": + return self._model + + def set_linear_coefficient(self, variable: Variable, coef: float) -> None: + self.model.check_compatible(variable) + self.model.storage.set_linear_objective_coefficient(variable.id, coef) + + def get_linear_coefficient(self, variable: Variable) -> float: + self.model.check_compatible(variable) + return self.model.storage.get_linear_objective_coefficient(variable.id) + + def linear_terms(self) -> Iterator[LinearTerm]: + """Yields variable coefficient pairs for variables with nonzero objective coefficient in undefined order.""" + yield from self.model.linear_objective_terms() + + def add(self, objective: QuadraticTypes) -> None: + """Adds the provided expression `objective` to the objective function. + + To ensure the objective remains linear through type checks, use + add_linear(). + + Args: + objective: the expression to add to the objective function. + """ + if isinstance(objective, (LinearBase, int, float)): + self.add_linear(objective) + elif isinstance(objective, QuadraticBase): + self.add_quadratic(objective) + else: + raise TypeError( + "unsupported type in objective argument for " + f"Objective.add(): {type(objective).__name__!r}" + ) + + def add_linear(self, objective: LinearTypes) -> None: + """Adds the provided linear expression `objective` to the objective function.""" + if not isinstance(objective, (LinearBase, int, float)): + raise TypeError( + "unsupported type in objective argument for " + f"Objective.add_linear(): {type(objective).__name__!r}" + ) + objective_expr = as_flat_linear_expression(objective) + self.offset += objective_expr.offset + for var, coefficient in objective_expr.terms.items(): + self.set_linear_coefficient( + var, self.get_linear_coefficient(var) + coefficient + ) + + def add_quadratic(self, objective: QuadraticTypes) -> None: + """Adds the provided quadratic expression `objective` to the objective function.""" + if not isinstance(objective, (QuadraticBase, LinearBase, int, float)): + raise TypeError( + "unsupported type in objective argument for " + f"Objective.add(): {type(objective).__name__!r}" + ) + objective_expr = as_flat_quadratic_expression(objective) + self.offset += objective_expr.offset + for var, coefficient in objective_expr.linear_terms.items(): + self.set_linear_coefficient( + var, self.get_linear_coefficient(var) + coefficient + ) + for key, coefficient in objective_expr.quadratic_terms.items(): + self.set_quadratic_coefficient( + key.first_var, + key.second_var, + self.get_quadratic_coefficient(key.first_var, key.second_var) + + coefficient, + ) + + def set_quadratic_coefficient( + self, first_variable: Variable, second_variable: Variable, coef: float + ) -> None: + self.model.check_compatible(first_variable) + self.model.check_compatible(second_variable) + self.model.storage.set_quadratic_objective_coefficient( + first_variable.id, second_variable.id, coef + ) + + def get_quadratic_coefficient( + self, first_variable: Variable, second_variable: Variable + ) -> float: + self.model.check_compatible(first_variable) + self.model.check_compatible(second_variable) + return self.model.storage.get_quadratic_objective_coefficient( + first_variable.id, second_variable.id + ) + + def quadratic_terms(self) -> Iterator[QuadraticTerm]: + """Yields quadratic terms with nonzero objective coefficient in undefined order.""" + yield from self.model.quadratic_objective_terms() + + def as_linear_expression(self) -> LinearExpression: + if any(self.quadratic_terms()): + raise TypeError("Cannot get a quadratic objective as a linear expression") + return as_flat_linear_expression(self.offset + LinearSum(self.linear_terms())) + + def as_quadratic_expression(self) -> QuadraticExpression: + return as_flat_quadratic_expression( + self.offset + + LinearSum(self.linear_terms()) + + QuadraticSum(self.quadratic_terms()) + ) + + def clear(self) -> None: + """Clears objective coefficients and offset. Does not change direction.""" + self.model.storage.clear_objective() + + +class LinearConstraintMatrixEntry(NamedTuple): + linear_constraint: LinearConstraint + variable: Variable + coefficient: float + + +class UpdateTracker: + """Tracks updates to an optimization model from a ModelStorage. + + Do not instantiate directly, instead create through + ModelStorage.add_update_tracker(). + + Querying an UpdateTracker after calling Model.remove_update_tracker will + result in a model_storage.UsedUpdateTrackerAfterRemovalError. + + Example: + mod = Model() + x = mod.add_variable(0.0, 1.0, True, 'x') + y = mod.add_variable(0.0, 1.0, True, 'y') + tracker = mod.add_update_tracker() + mod.set_variable_ub(x, 3.0) + tracker.export_update() + => "variable_updates: {upper_bounds: {ids: [0], values[3.0] }" + mod.set_variable_ub(y, 2.0) + tracker.export_update() + => "variable_updates: {upper_bounds: {ids: [0, 1], values[3.0, 2.0] }" + tracker.advance_checkpoint() + tracker.export_update() + => None + mod.set_variable_ub(y, 4.0) + tracker.export_update() + => "variable_updates: {upper_bounds: {ids: [1], values[4.0] }" + tracker.advance_checkpoint() + mod.remove_update_tracker(tracker) + """ + + def __init__(self, storage_update_tracker: model_storage.StorageUpdateTracker): + """Do not invoke directly, use Model.add_update_tracker() instead.""" + self.storage_update_tracker = storage_update_tracker + + def export_update(self) -> Optional[model_update_pb2.ModelUpdateProto]: + """Returns changes to the model since last call to checkpoint/creation.""" + return self.storage_update_tracker.export_update() + + def advance_checkpoint(self) -> None: + """Track changes to the model only after this function call.""" + return self.storage_update_tracker.advance_checkpoint() + + +class Model: + """An optimization model. + + The objective function of the model can be linear or quadratic, and some + solvers can only handle linear objective functions. For this reason Model has + three versions of all objective setting functions: + * A generic one (e.g. maximize()), which accepts linear or quadratic + expressions, + * a quadratic version (e.g. maximize_quadratic_objective()), which also + accepts linear or quadratic expressions and can be used to signal a + quadratic objective is possible, and + * a linear version (e.g. maximize_linear_objective()), which only accepts + linear expressions and can be used to avoid solve time errors for solvers + that do not accept quadratic objectives. + + Attributes: + name: A description of the problem, can be empty. + objective: A function to maximize or minimize. + storage: Implementation detail, do not access directly. + _variable_ids: Maps variable ids to Variable objects. + _linear_constraint_ids: Maps linear constraint ids to LinearConstraint + objects. + """ + + __slots__ = "storage", "_variable_ids", "_linear_constraint_ids", "_objective" + + def __init__( + self, + *, + name: str = "", + storage_class: StorageClass = hash_model_storage.HashModelStorage, + ) -> None: + self.storage: Storage = storage_class(name) + # Weak references are used to eliminate reference cycles (so that Model will + # be destroyed when we reach zero references, instead of relying on GC cycle + # detection). Do not access a variable or constraint from these maps + # directly, as they may no longer exist, use _get_or_make_variable() and + # _get_or_make_linear_constraint() defined below instead. + self._variable_ids: weakref.WeakValueDictionary[ + int, Variable + ] = weakref.WeakValueDictionary() + self._linear_constraint_ids: weakref.WeakValueDictionary[ + int, LinearConstraint + ] = weakref.WeakValueDictionary() + self._objective: Objective = Objective(self) + + @property + def name(self) -> str: + return self.storage.name + + @property + def objective(self) -> Objective: + return self._objective + + def add_variable( + self, + *, + lb: float = -math.inf, + ub: float = math.inf, + is_integer: bool = False, + name: str = "", + ) -> Variable: + """Adds a decision variable to the optimization model. + + Args: + lb: The new variable must take at least this value (a lower bound). + ub: The new variable must be at most this value (an upper bound). + is_integer: Indicates if the variable can only take integer values + (otherwise, the variable can take any continuous value). + name: For debugging purposes only, but nonempty names must be distinct. + + Returns: + A reference to the new decision variable. + """ + variable_id = self.storage.add_variable(lb, ub, is_integer, name) + result = Variable(self, variable_id) + self._variable_ids[variable_id] = result + return result + + def add_integer_variable( + self, *, lb: float = -math.inf, ub: float = math.inf, name: str = "" + ) -> Variable: + return self.add_variable(lb=lb, ub=ub, is_integer=True, name=name) + + def add_binary_variable(self, *, name: str = "") -> Variable: + return self.add_variable(lb=0.0, ub=1.0, is_integer=True, name=name) + + def get_variable(self, var_id: int) -> Variable: + """Returns the Variable for the id var_id, or raises KeyError.""" + if not self.storage.variable_exists(var_id): + raise KeyError(f"variable does not exist with id {var_id}") + return self._get_or_make_variable(var_id) + + def delete_variable(self, variable: Variable) -> None: + self.check_compatible(variable) + self.storage.delete_variable(variable.id) + del self._variable_ids[variable.id] + + def variables(self) -> Iterator[Variable]: + """Yields the variables in the order of creation.""" + for var_id in self.storage.get_variables(): + yield self._get_or_make_variable(var_id) + + def maximize(self, objective: QuadraticTypes) -> None: + """Sets the objective to maximize the provided expression `objective`.""" + self.set_objective(objective, is_maximize=True) + + def maximize_linear_objective(self, objective: LinearTypes) -> None: + """Sets the objective to maximize the provided linear expression `objective`.""" + self.set_linear_objective(objective, is_maximize=True) + + def maximize_quadratic_objective(self, objective: QuadraticTypes) -> None: + """Sets the objective to maximize the provided quadratic expression `objective`.""" + self.set_quadratic_objective(objective, is_maximize=True) + + def minimize(self, objective: QuadraticTypes) -> None: + """Sets the objective to minimize the provided expression `objective`.""" + self.set_objective(objective, is_maximize=False) + + def minimize_linear_objective(self, objective: LinearTypes) -> None: + """Sets the objective to minimize the provided linear expression `objective`.""" + self.set_linear_objective(objective, is_maximize=False) + + def minimize_quadratic_objective(self, objective: QuadraticTypes) -> None: + """Sets the objective to minimize the provided quadratic expression `objective`.""" + self.set_quadratic_objective(objective, is_maximize=False) + + def set_objective(self, objective: QuadraticTypes, *, is_maximize: bool) -> None: + """Sets the objective to optimize the provided expression `objective`.""" + if isinstance(objective, (LinearBase, int, float)): + self.set_linear_objective(objective, is_maximize=is_maximize) + elif isinstance(objective, QuadraticBase): + self.set_quadratic_objective(objective, is_maximize=is_maximize) + else: + raise TypeError( + "unsupported type in objective argument for " + f"set_objective: {type(objective).__name__!r}" + ) + + def set_linear_objective( + self, objective: LinearTypes, *, is_maximize: bool + ) -> None: + """Sets the objective to optimize the provided linear expression `objective`.""" + if not isinstance(objective, (LinearBase, int, float)): + raise TypeError( + "unsupported type in objective argument for " + f"set_linear_objective: {type(objective).__name__!r}" + ) + self.storage.clear_objective() + self._objective.is_maximize = is_maximize + objective_expr = as_flat_linear_expression(objective) + self._objective.offset = objective_expr.offset + for var, coefficient in objective_expr.terms.items(): + self._objective.set_linear_coefficient(var, coefficient) + + def set_quadratic_objective( + self, objective: QuadraticTypes, *, is_maximize: bool + ) -> None: + """Sets the objective to optimize the provided linear expression `objective`.""" + if not isinstance(objective, (QuadraticBase, LinearBase, int, float)): + raise TypeError( + "unsupported type in objective argument for " + f"set_quadratic_objective: {type(objective).__name__!r}" + ) + self.storage.clear_objective() + self._objective.is_maximize = is_maximize + objective_expr = as_flat_quadratic_expression(objective) + self._objective.offset = objective_expr.offset + for var, coefficient in objective_expr.linear_terms.items(): + self._objective.set_linear_coefficient(var, coefficient) + for key, coefficient in objective_expr.quadratic_terms.items(): + self._objective.set_quadratic_coefficient( + key.first_var, key.second_var, coefficient + ) + + # TODO(b/227214976): Update the note below and link to pytype bug number. + # Note: bounded_expr's type includes bool only as a workaround to a pytype + # issue. Passing a bool for bounded_expr will raise an error in runtime. + def add_linear_constraint( + self, + bounded_expr: Optional[Union[bool, BoundedLinearTypes]] = None, + *, + lb: Optional[float] = None, + ub: Optional[float] = None, + expr: Optional[LinearTypes] = None, + name: str = "", + ) -> LinearConstraint: + """Adds a linear constraint to the optimization model. + + The simplest way to specify the constraint is by passing a one-sided or + two-sided linear inequality as in: + * add_linear_constraint(x + y + 1.0 <= 2.0), + * add_linear_constraint(x + y >= 2.0), or + * add_linear_constraint((1.0 <= x + y) <= 2.0). + + Note the extra parenthesis for two-sided linear inequalities, which is + required due to some language limitations (see + https://peps.python.org/pep-0335/ and https://peps.python.org/pep-0535/). + If the parenthesis are omitted, a TypeError will be raised explaining the + issue (if this error was not raised the first inequality would have been + silently ignored because of the noted language limitations). + + The second way to specify the constraint is by setting lb, ub, and/o expr as + in: + * add_linear_constraint(expr=x + y + 1.0, ub=2.0), + * add_linear_constraint(expr=x + y, lb=2.0), + * add_linear_constraint(expr=x + y, lb=1.0, ub=2.0), or + * add_linear_constraint(lb=1.0). + Omitting lb is equivalent to setting it to -math.inf and omiting ub is + equivalent to setting it to math.inf. + + These two alternatives are exclusive and a combined call like: + * add_linear_constraint(x + y <= 2.0, lb=1.0), or + * add_linear_constraint(x + y <= 2.0, ub=math.inf) + will raise a ValueError. A ValueError is also raised if expr's offset is + infinite. + + Args: + bounded_expr: a linear inequality describing the constraint. Cannot be + specified together with lb, ub, or expr. + lb: The constraint's lower bound if bounded_expr is omitted (if both + bounder_expr and lb are omitted, the lower bound is -math.inf). + ub: The constraint's upper bound if bounded_expr is omitted (if both + bounder_expr and ub are omitted, the upper bound is math.inf). + expr: The constraint's linear expression if bounded_expr is omitted. + name: For debugging purposes only, but nonempty names must be distinct. + + Returns: + A reference to the new linear constraint. + """ + normalized_inequality = as_normalized_linear_inequality( + bounded_expr, lb=lb, ub=ub, expr=expr + ) + lin_con_id = self.storage.add_linear_constraint( + normalized_inequality.lb, normalized_inequality.ub, name + ) + result = LinearConstraint(self, lin_con_id) + self._linear_constraint_ids[lin_con_id] = result + for var, coefficient in normalized_inequality.coefficients.items(): + result.set_coefficient(var, coefficient) + return result + + def get_linear_constraint(self, lin_con_id: int) -> LinearConstraint: + """Returns the LinearConstraint for the id lin_con_id.""" + if not self.storage.linear_constraint_exists(lin_con_id): + raise KeyError(f"linear constraint does not exist with id {lin_con_id}") + return self._get_or_make_linear_constraint(lin_con_id) + + def delete_linear_constraint(self, linear_constraint: LinearConstraint) -> None: + self.check_compatible(linear_constraint) + self.storage.delete_linear_constraint(linear_constraint.id) + del self._linear_constraint_ids[linear_constraint.id] + + def linear_constraints(self) -> Iterator[LinearConstraint]: + """Yields the linear constraints in the order of creation.""" + for lin_con_id in self.storage.get_linear_constraints(): + yield self._get_or_make_linear_constraint(lin_con_id) + + def row_nonzeros(self, linear_constraint: LinearConstraint) -> Iterator[Variable]: + """Yields the variables with nonzero coefficient for this linear constraint.""" + for var_id in self.storage.get_variables_for_linear_constraint( + linear_constraint.id + ): + yield self._get_or_make_variable(var_id) + + def column_nonzeros(self, variable: Variable) -> Iterator[LinearConstraint]: + """Yields the linear constraints with nonzero coefficient for this variable.""" + for lin_con_id in self.storage.get_linear_constraints_with_variable( + variable.id + ): + yield self._get_or_make_linear_constraint(lin_con_id) + + def linear_objective_terms(self) -> Iterator[LinearTerm]: + """Yields variable coefficient pairs for variables with nonzero objective coefficient in undefined order.""" + for term in self.storage.get_linear_objective_coefficients(): + yield LinearTerm( + variable=self._get_or_make_variable(term.variable_id), + coefficient=term.coefficient, + ) + + def quadratic_objective_terms(self) -> Iterator[QuadraticTerm]: + """Yields the quadratic terms with nonzero objective coefficient in undefined order.""" + for term in self.storage.get_quadratic_objective_coefficients(): + var1 = self._get_or_make_variable(term.id_key.id1) + var2 = self._get_or_make_variable(term.id_key.id2) + yield QuadraticTerm( + key=QuadraticTermKey(var1, var2), coefficient=term.coefficient + ) + + def linear_constraint_matrix_entries( + self, + ) -> Iterator[LinearConstraintMatrixEntry]: + """Yields the nonzero elements of the linear constraint matrix in undefined order.""" + for entry in self.storage.get_linear_constraint_matrix_entries(): + yield LinearConstraintMatrixEntry( + linear_constraint=self._get_or_make_linear_constraint( + entry.linear_constraint_id + ), + variable=self._get_or_make_variable(entry.variable_id), + coefficient=entry.coefficient, + ) + + def export_model(self) -> model_pb2.ModelProto: + return self.storage.export_model() + + def add_update_tracker(self) -> UpdateTracker: + """Creates an UpdateTracker registered on this model to view changes.""" + return UpdateTracker(self.storage.add_update_tracker()) + + def remove_update_tracker(self, tracker: UpdateTracker): + """Stops tracker from getting updates on changes to this model. + + An error will be raised if tracker was not created by this Model or if + tracker has been previously removed. + + Using (via checkpoint or update) an UpdateTracker after it has been removed + will result in an error. + + Args: + tracker: The UpdateTracker to unregister. + + Raises: + KeyError: The tracker was created by another model or was already removed. + """ + self.storage.remove_update_tracker(tracker.storage_update_tracker) + + def check_compatible( + self, var_or_constraint: Union[Variable, LinearConstraint] + ) -> None: + """Raises a ValueError if the model of var_or_constraint is not self.""" + if var_or_constraint.model is not self: + raise ValueError( + f"{var_or_constraint} is from model {var_or_constraint.model} and" + f" cannot be used with model {self}" + ) + + def _get_or_make_variable(self, variable_id: int) -> Variable: + result = self._variable_ids.get(variable_id) + if result: + return result + result = Variable(self, variable_id) + self._variable_ids[variable_id] = result + return result + + def _get_or_make_linear_constraint( + self, linear_constraint_id: int + ) -> LinearConstraint: + result = self._linear_constraint_ids.get(linear_constraint_id) + if result: + return result + result = LinearConstraint(self, linear_constraint_id) + self._linear_constraint_ids[linear_constraint_id] = result + return result + + +class LinearSum(LinearBase): + # TODO(b/216492143): consider what details to move elsewhere and/or replace + # by examples, and do complexity analysis. + """A deferred sum of LinearBase objects. + + LinearSum objects are automatically created when two linear objects are added + and, as noted in the documentation for Linear, can reduce the inefficiencies. + In particular, they are created when calling sum(iterable) when iterable is + an Iterable[LinearTypes]. However, using LinearSum(iterable) instead + can result in additional performance improvements: + + * sum(iterable): creates a nested set of LinearSum objects (e.g. + `sum([a, b, c])` is `LinearSum(0, LinearSum(a, LinearSum(b, c)))`). + * LinearSum(iterable): creates a single LinearSum that saves a tuple with + all the LinearTypes objects in iterable (e.g. + `LinearSum([a, b, c])` does not create additional objects). + + This class is immutable. + """ + + __slots__ = "__weakref__", "_elements" + + # Potentially unsafe use of Iterable argument is handled by immediate local + # storage as tuple. + def __init__(self, iterable: Iterable[LinearTypes]) -> None: + """Creates a LinearSum object. A copy of iterable is saved as a tuple.""" + + self._elements = tuple(iterable) + for item in self._elements: + if not isinstance(item, (LinearBase, int, float)): + raise TypeError( + "unsupported type in iterable argument for " + f"LinearSum: {type(item).__name__!r}" + ) + + @property + def elements(self) -> Tuple[LinearTypes, ...]: + return self._elements + + def _flatten_once_and_add_to( + self, + scale: float, + processed_elements: _ProcessedElements, + target_stack: _ToProcessElements, + ) -> None: + for term in self._elements: + if isinstance(term, (int, float)): + processed_elements.offset += scale * float(term) + else: + target_stack.append(term, scale) + + def __str__(self): + return str(as_flat_linear_expression(self)) + + def __repr__(self): + result = "LinearSum((" + result += ", ".join(repr(linear) for linear in self._elements) + result += "))" + return result + + +class QuadraticSum(QuadraticBase): + # TODO(b/216492143): consider what details to move elsewhere and/or replace + # by examples, and do complexity analysis. + """A deferred sum of QuadraticTypes objects. + + QuadraticSum objects are automatically created when a quadratic object is + added to quadratic or linear objects and, as has performance optimizations + similar to LinearSum. + + This class is immutable. + """ + + __slots__ = "__weakref__", "_elements" + + # Potentially unsafe use of Iterable argument is handled by immediate local + # storage as tuple. + def __init__(self, iterable: Iterable[QuadraticTypes]) -> None: + """Creates a QuadraticSum object. A copy of iterable is saved as a tuple.""" + + self._elements = tuple(iterable) + for item in self._elements: + if not isinstance(item, (LinearBase, QuadraticBase, int, float)): + raise TypeError( + "unsupported type in iterable argument for " + f"QuadraticSum: {type(item).__name__!r}" + ) + + @property + def elements(self) -> Tuple[QuadraticTypes, ...]: + return self._elements + + def _quadratic_flatten_once_and_add_to( + self, + scale: float, + processed_elements: _QuadraticProcessedElements, + target_stack: _QuadraticToProcessElements, + ) -> None: + for term in self._elements: + if isinstance(term, (int, float)): + processed_elements.offset += scale * float(term) + else: + target_stack.append(term, scale) + + def __str__(self): + return str(as_flat_quadratic_expression(self)) + + def __repr__(self): + result = "QuadraticSum((" + result += ", ".join(repr(element) for element in self._elements) + result += "))" + return result + + +class LinearProduct(LinearBase): + """A deferred multiplication computation for linear expressions. + + This class is immutable. + """ + + __slots__ = "_scalar", "_linear" + + def __init__(self, scalar: float, linear: LinearBase) -> None: + if not isinstance(scalar, (float, int)): + raise TypeError( + "unsupported type for scalar argument in " + f"LinearProduct: {type(scalar).__name__!r}" + ) + if not isinstance(linear, LinearBase): + raise TypeError( + "unsupported type for linear argument in " + f"LinearProduct: {type(linear).__name__!r}" + ) + self._scalar: float = float(scalar) + self._linear: LinearBase = linear + + @property + def scalar(self) -> float: + return self._scalar + + @property + def linear(self) -> LinearBase: + return self._linear + + def _flatten_once_and_add_to( + self, + scale: float, + processed_elements: _ProcessedElements, + target_stack: _ToProcessElements, + ) -> None: + target_stack.append(self._linear, self._scalar * scale) + + def __str__(self): + return str(as_flat_linear_expression(self)) + + def __repr__(self): + result = f"LinearProduct({self._scalar!r}, " + result += f"{self._linear!r})" + return result + + +class QuadraticProduct(QuadraticBase): + """A deferred multiplication computation for quadratic expressions. + + This class is immutable. + """ + + __slots__ = "_scalar", "_quadratic" + + def __init__(self, scalar: float, quadratic: QuadraticBase) -> None: + if not isinstance(scalar, (float, int)): + raise TypeError( + "unsupported type for scalar argument in " + f"QuadraticProduct: {type(scalar).__name__!r}" + ) + if not isinstance(quadratic, QuadraticBase): + raise TypeError( + "unsupported type for linear argument in " + f"QuadraticProduct: {type(quadratic).__name__!r}" + ) + self._scalar: float = float(scalar) + self._quadratic: QuadraticBase = quadratic + + @property + def scalar(self) -> float: + return self._scalar + + @property + def quadratic(self) -> QuadraticBase: + return self._quadratic + + def _quadratic_flatten_once_and_add_to( + self, + scale: float, + processed_elements: _QuadraticProcessedElements, + target_stack: _QuadraticToProcessElements, + ) -> None: + target_stack.append(self._quadratic, self._scalar * scale) + + def __str__(self): + return str(as_flat_quadratic_expression(self)) + + def __repr__(self): + return f"QuadraticProduct({self._scalar}, {self._quadratic!r})" + + +class LinearLinearProduct(QuadraticBase): + """A deferred multiplication of two linear expressions. + + This class is immutable. + """ + + __slots__ = "_first_linear", "_second_linear" + + def __init__(self, first_linear: LinearBase, second_linear: LinearBase) -> None: + if not isinstance(first_linear, LinearBase): + raise TypeError( + "unsupported type for first_linear argument in " + f"LinearLinearProduct: {type(first_linear).__name__!r}" + ) + if not isinstance(second_linear, LinearBase): + raise TypeError( + "unsupported type for second_linear argument in " + f"LinearLinearProduct: {type(second_linear).__name__!r}" + ) + self._first_linear: LinearBase = first_linear + self._second_linear: LinearBase = second_linear + + @property + def first_linear(self) -> LinearBase: + return self._first_linear + + @property + def second_linear(self) -> LinearBase: + return self._second_linear + + def _quadratic_flatten_once_and_add_to( + self, + scale: float, + processed_elements: _QuadraticProcessedElements, + target_stack: _QuadraticToProcessElements, + ) -> None: + # A recursion is avoided here because as_flat_linear_expression() must never + # call _quadratic_flatten_once_and_add_to(). + first_expression = as_flat_linear_expression(self._first_linear) + second_expression = as_flat_linear_expression(self._second_linear) + processed_elements.offset += ( + first_expression.offset * second_expression.offset * scale + ) + for first_var, first_val in first_expression.terms.items(): + processed_elements.terms[first_var] += ( + second_expression.offset * first_val * scale + ) + for second_var, second_val in second_expression.terms.items(): + processed_elements.terms[second_var] += ( + first_expression.offset * second_val * scale + ) + + for first_var, first_val in first_expression.terms.items(): + for second_var, second_val in second_expression.terms.items(): + processed_elements.quadratic_terms[ + QuadraticTermKey(first_var, second_var) + ] += (first_val * second_val * scale) + + def __str__(self): + return str(as_flat_quadratic_expression(self)) + + def __repr__(self): + result = "LinearLinearProduct(" + result += f"{self._first_linear!r}, " + result += f"{self._second_linear!r})" + return result + + +def as_flat_linear_expression(value: LinearTypes) -> LinearExpression: + """Converts floats, ints and Linear objects to a LinearExpression.""" + if isinstance(value, LinearExpression): + return value + return LinearExpression(value) + + +def as_flat_quadratic_expression(value: QuadraticTypes) -> QuadraticExpression: + """Converts floats, ints, LinearBase and QuadraticBase objects to a QuadraticExpression.""" + if isinstance(value, QuadraticExpression): + return value + return QuadraticExpression(value) + + +@dataclasses.dataclass +class NormalizedLinearInequality: + """Represents an inequality lb <= expr <= ub where expr's offset is zero. + + The inequality is of the form: + lb <= sum_{x in V} coefficients[x] * x <= ub + where V is the set of keys of coefficients. + """ + + lb: float + ub: float + coefficients: Mapping[Variable, float] + + def __init__(self, *, lb: float, ub: float, expr: LinearTypes) -> None: + """Raises a ValueError if expr's offset is infinite.""" + flat_expr = as_flat_linear_expression(expr) + if math.isinf(flat_expr.offset): + raise ValueError( + "Trying to create a linear constraint whose expression has an" + " infinite offset." + ) + self.lb = lb - flat_expr.offset + self.ub = ub - flat_expr.offset + self.coefficients = flat_expr.terms + + +# TODO(b/227214976): Update the note below and link to pytype bug number. +# Note: bounded_expr's type includes bool only as a workaround to a pytype +# issue. Passing a bool for bounded_expr will raise an error in runtime. +def as_normalized_linear_inequality( + bounded_expr: Optional[Union[bool, BoundedLinearTypes]] = None, + *, + lb: Optional[float] = None, + ub: Optional[float] = None, + expr: Optional[LinearTypes] = None, +) -> NormalizedLinearInequality: + """Converts a linear constraint to a NormalizedLinearInequality. + + The simplest way to specify the constraint is by passing a one-sided or + two-sided linear inequality as in: + * as_normalized_linear_inequality(x + y + 1.0 <= 2.0), + * as_normalized_linear_inequality(x + y >= 2.0), or + * as_normalized_linear_inequality((1.0 <= x + y) <= 2.0). + + Note the extra parenthesis for two-sided linear inequalities, which is + required due to some language limitations (see + https://peps.python.org/pep-0335/ and https://peps.python.org/pep-0535/). + If the parenthesis are omitted, a TypeError will be raised explaining the + issue (if this error was not raised the first inequality would have been + silently ignored because of the noted language limitations). + + The second way to specify the constraint is by setting lb, ub, and/o expr as + in: + * as_normalized_linear_inequality(expr=x + y + 1.0, ub=2.0), + * as_normalized_linear_inequality(expr=x + y, lb=2.0), + * as_normalized_linear_inequality(expr=x + y, lb=1.0, ub=2.0), or + * as_normalized_linear_inequality(lb=1.0). + Omitting lb is equivalent to setting it to -math.inf and omiting ub is + equivalent to setting it to math.inf. + + These two alternatives are exclusive and a combined call like: + * as_normalized_linear_inequality(x + y <= 2.0, lb=1.0), or + * as_normalized_linear_inequality(x + y <= 2.0, ub=math.inf) + will raise a ValueError. A ValueError is also raised if expr's offset is + infinite. + + Args: + bounded_expr: a linear inequality describing the constraint. Cannot be + specified together with lb, ub, or expr. + lb: The constraint's lower bound if bounded_expr is omitted (if both + bounder_expr and lb are omitted, the lower bound is -math.inf). + ub: The constraint's upper bound if bounded_expr is omitted (if both + bounder_expr and ub are omitted, the upper bound is math.inf). + expr: The constraint's linear expression if bounded_expr is omitted. + + Returns: + A NormalizedLinearInequality representing the linear constraint. + """ + if bounded_expr is None: + if lb is None: + lb = -math.inf + if ub is None: + ub = math.inf + if expr is None: + return NormalizedLinearInequality(lb=lb, ub=ub, expr=0) + if not isinstance(expr, (LinearBase, int, float)): + raise TypeError( + f"unsupported type for expr argument: {type(expr).__name__!r}" + ) + return NormalizedLinearInequality(lb=lb, ub=ub, expr=expr) + + if isinstance(bounded_expr, bool): + raise TypeError( + "unsupported type for bounded_expr argument:" + " bool. This error can occur when trying to add != constraints " + "(which are not supported) or inequalities/equalities with constant " + "left-hand-side and right-hand-side (which are redundant or make a " + "model infeasible)." + ) + if not isinstance( + bounded_expr, + ( + LowerBoundedLinearExpression, + UpperBoundedLinearExpression, + BoundedLinearExpression, + VarEqVar, + ), + ): + raise TypeError( + f"unsupported type for bounded_expr: {type(bounded_expr).__name__!r}" + ) + if lb is not None: + raise ValueError("lb cannot be specified together with a linear inequality") + if ub is not None: + raise ValueError("ub cannot be specified together with a linear inequality") + if expr is not None: + raise ValueError("expr cannot be specified together with a linear inequality") + + if isinstance(bounded_expr, VarEqVar): + return NormalizedLinearInequality( + lb=0.0, + ub=0.0, + expr=bounded_expr.first_variable - bounded_expr.second_variable, + ) + + if isinstance( + bounded_expr, (LowerBoundedLinearExpression, BoundedLinearExpression) + ): + lb = bounded_expr.lower_bound + else: + lb = -math.inf + if isinstance( + bounded_expr, (UpperBoundedLinearExpression, BoundedLinearExpression) + ): + ub = bounded_expr.upper_bound + else: + ub = math.inf + return NormalizedLinearInequality(lb=lb, ub=ub, expr=bounded_expr.expression) diff --git a/ortools/math_opt/python/model_parameters.py b/ortools/math_opt/python/model_parameters.py new file mode 100644 index 0000000000..41d6f36b63 --- /dev/null +++ b/ortools/math_opt/python/model_parameters.py @@ -0,0 +1,155 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Model specific solver configuration (e.g. starting basis).""" +import dataclasses +from typing import Dict, List, Optional + +from ortools.math_opt import model_parameters_pb2 +from ortools.math_opt.python import model +from ortools.math_opt.python import solution +from ortools.math_opt.python import sparse_containers + + +@dataclasses.dataclass +class SolutionHint: + """A suggested starting solution for the solver. + + MIP solvers generally only want primal information (`variable_values`), + while LP solvers want both primal and dual information (`dual_values`). + + Many MIP solvers can work with: (1) partial solutions that do not specify all + variables or (2) infeasible solutions. In these cases, solvers typically solve + a sub-MIP to complete/correct the hint. + + How the hint is used by the solver, if at all, is highly dependent on the + solver, the problem type, and the algorithm used. The most reliable way to + ensure your hint has an effect is to read the underlying solvers logs with + and without the hint. + + Simplex-based LP solvers typically prefer an initial basis to a solution + hint (they need to crossover to convert the hint to a basic feasible + solution otherwise). + + Floating point values should be finite and not NaN, they are validated by + MathOpt at Solve() time (resulting in an exception). + + Attributes: + variable_values: a potentially partial assignment from the model's primal + variables to finite (and not NaN) double values. + dual_values: a potentially partial assignment from the model's linear + constraints to finite (and not NaN) double values. + """ + + variable_values: Dict[model.Variable, float] = dataclasses.field( + default_factory=dict + ) + dual_values: Dict[model.LinearConstraint, float] = dataclasses.field( + default_factory=dict + ) + + def to_proto(self) -> model_parameters_pb2.SolutionHintProto: + """Returns an equivalent protocol buffer to this.""" + return model_parameters_pb2.SolutionHintProto( + variable_values=sparse_containers.to_sparse_double_vector_proto( + self.variable_values + ), + dual_values=sparse_containers.to_sparse_double_vector_proto( + self.dual_values + ), + ) + + +def parse_solution_hint( + hint_proto: model_parameters_pb2.SolutionHintProto, mod: model.Model +) -> SolutionHint: + """Returns an equivalent SolutionHint to `hint_proto`. + + Args: + hint_proto: The solution, as encoded by the ids of the variables and + constraints. + mod: A MathOpt Model that must contain variables and linear constraints with + the ids from hint_proto. + + Returns: + A SolutionHint equivalent. + + Raises: + ValueError if hint_proto is invalid or refers to variables or constraints + not in mod. + """ + return SolutionHint( + variable_values=sparse_containers.parse_variable_map( + hint_proto.variable_values, mod + ), + dual_values=sparse_containers.parse_linear_constraint_map( + hint_proto.dual_values, mod + ), + ) + + +@dataclasses.dataclass +class ModelSolveParameters: + """Model specific solver configuration, for example, an initial basis. + + This class mirrors (and can generate) the related proto + model_parameters_pb2.ModelSolveParametersProto. + + Attributes: + variable_values_filter: Only return solution and primal ray values for + variables accepted by this filter (default accepts all variables). + dual_values_filter: Only return dual variable values and dual ray values for + linear constraints accepted by thei filter (default accepts all linear + constraints). + reduced_costs_filter: Only return reduced cost and dual ray values for + variables accepted by this filter (default accepts all variables). + initial_basis: If set, provides a warm start for simplex based solvers. + solution_hints: Optional solution hints. If the underlying solver only + accepts a single hint, the first hint is used. + branching_priorities: Optional branching priorities. Variables with higher + values will be branched on first. Variables for which priorities are not + set get the solver's default priority (usually zero). + """ + + variable_values_filter: sparse_containers.VariableFilter = ( + sparse_containers.VariableFilter() + ) + dual_values_filter: sparse_containers.LinearConstraintFilter = ( + sparse_containers.LinearConstraintFilter() + ) + reduced_costs_filter: sparse_containers.VariableFilter = ( + sparse_containers.VariableFilter() + ) + initial_basis: Optional[solution.Basis] = None + solution_hints: List[SolutionHint] = dataclasses.field(default_factory=list) + branching_priorities: Dict[model.Variable, int] = dataclasses.field( + default_factory=dict + ) + + def to_proto(self) -> model_parameters_pb2.ModelSolveParametersProto: + """Returns an equivalent protocol buffer.""" + # TODO(b/236289022): these methods should check that the variables are from + # the correct model. + result = model_parameters_pb2.ModelSolveParametersProto( + variable_values_filter=self.variable_values_filter.to_proto(), + dual_values_filter=self.dual_values_filter.to_proto(), + reduced_costs_filter=self.reduced_costs_filter.to_proto(), + branching_priorities=sparse_containers.to_sparse_int32_vector_proto( + self.branching_priorities + ), + ) + if self.initial_basis: + result.initial_basis.CopyFrom(self.initial_basis.to_proto()) + for hint in self.solution_hints: + result.solution_hints.append(hint.to_proto()) + return result diff --git a/ortools/math_opt/python/model_parameters_test.py b/ortools/math_opt/python/model_parameters_test.py new file mode 100644 index 0000000000..2d8adcf987 --- /dev/null +++ b/ortools/math_opt/python/model_parameters_test.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from ortools.math_opt import model_parameters_pb2 +from ortools.math_opt import solution_pb2 +from ortools.math_opt import sparse_containers_pb2 +from ortools.math_opt.python import model +from ortools.math_opt.python import model_parameters +from ortools.math_opt.python import solution +from ortools.math_opt.python import sparse_containers +from ortools.math_opt.python.testing import compare_proto + + +class ModelParametersTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_solution_hint_round_trip(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + + hint = model_parameters.SolutionHint( + variable_values={x: 2.0, y: 3.0}, dual_values={c: 4.0, d: 5.0} + ) + hint_round_trip = model_parameters.parse_solution_hint(hint.to_proto(), mod) + self.assertDictEqual(hint_round_trip.variable_values, hint.variable_values) + self.assertDictEqual(hint_round_trip.dual_values, hint.dual_values) + + def test_model_parameters_to_proto_no_basis(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + params = model_parameters.ModelSolveParameters() + params.variable_values_filter = sparse_containers.SparseVectorFilter( + filtered_items=(y,) + ) + params.reduced_costs_filter = sparse_containers.SparseVectorFilter( + skip_zero_values=True + ) + params.dual_values_filter = sparse_containers.SparseVectorFilter( + filtered_items=(c,) + ) + params.solution_hints.append( + model_parameters.SolutionHint( + variable_values={x: 1.0, y: 1.0}, dual_values={c: 3.0} + ) + ) + params.solution_hints.append( + model_parameters.SolutionHint(variable_values={y: 0.0}) + ) + params.branching_priorities[y] = 2 + actual = params.to_proto() + expected = model_parameters_pb2.ModelSolveParametersProto( + variable_values_filter=sparse_containers_pb2.SparseVectorFilterProto( + filter_by_ids=True, filtered_ids=(1,) + ), + reduced_costs_filter=sparse_containers_pb2.SparseVectorFilterProto( + skip_zero_values=True + ), + dual_values_filter=sparse_containers_pb2.SparseVectorFilterProto( + filter_by_ids=True, filtered_ids=(0,) + ), + branching_priorities=sparse_containers_pb2.SparseInt32VectorProto( + ids=[1], values=[2] + ), + ) + h1 = expected.solution_hints.add() + h1.variable_values.ids[:] = [0, 1] + h1.variable_values.values[:] = [1.0, 1.0] + h1.dual_values.ids[:] = [0] + h1.dual_values.values[:] = [3] + h2 = expected.solution_hints.add() + h2.variable_values.ids.append(1) + h2.variable_values.values.append(0.0) + self.assert_protos_equiv(actual, expected) + + def test_model_parameters_to_proto_with_basis(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + params = model_parameters.ModelSolveParameters() + params.initial_basis = solution.Basis() + params.initial_basis.variable_status[x] = solution.BasisStatus.AT_UPPER_BOUND + actual = params.to_proto() + expected = model_parameters_pb2.ModelSolveParametersProto() + expected.initial_basis.variable_status.ids.append(0) + expected.initial_basis.variable_status.values.append( + solution_pb2.BASIS_STATUS_AT_UPPER_BOUND + ) + expected.initial_basis.basic_dual_feasibility = ( + solution_pb2.SOLUTION_STATUS_UNDETERMINED + ) + self.assert_protos_equiv(expected, actual) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/model_storage.py b/ortools/math_opt/python/model_storage.py new file mode 100644 index 0000000000..421086255c --- /dev/null +++ b/ortools/math_opt/python/model_storage.py @@ -0,0 +1,440 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""An interface for in memory storage of optimization problems.""" + +import abc +import dataclasses +from typing import Iterator, Optional, Type, TypeVar + +from ortools.math_opt import model_pb2 +from ortools.math_opt import model_update_pb2 + + +# TODO(b/231426528): remove __slots__ and set slots=True when Python 3.10 is +# available. +@dataclasses.dataclass(frozen=True) +class LinearConstraintMatrixIdEntry: + __slots__ = "linear_constraint_id", "variable_id", "coefficient" + linear_constraint_id: int + variable_id: int + coefficient: float + + +# TODO(b/231426528): remove __slots__ and set slots=True when Python 3.10 is +# available. +@dataclasses.dataclass(frozen=True) +class LinearObjectiveEntry: + __slots__ = "variable_id", "coefficient" + variable_id: int + coefficient: float + + +# TODO(b/231426528): remove __slots__ and set slots=True when Python 3.10 is +# available. +@dataclasses.dataclass(frozen=True) +class QuadraticTermIdKey: + """An ordered pair of ints used as a key for quadratic terms. + + QuadraticTermIdKey.id1 <= QuadraticTermIdKey.id2. + """ + + __slots__ = "id1", "id2" + id1: int + id2: int + + def __init__(self, a: int, b: int): + """Ints a and b will be ordered internally.""" + id1 = a + id2 = b + if id1 > id2: + id1, id2 = id2, id1 + object.__setattr__(self, "id1", id1) + object.__setattr__(self, "id2", id2) + + +# TODO(b/231426528): remove __slots__ and set slots=True when Python 3.10 is +# available. +@dataclasses.dataclass(frozen=True) +class QuadraticEntry: + """Represents an id-indexed quadratic term.""" + + __slots__ = "id_key", "coefficient" + id_key: QuadraticTermIdKey + coefficient: float + + +class StorageUpdateTracker(abc.ABC): + """Tracks updates to an optimization model from a ModelStorage. + + Do not instantiate directly, instead create through + ModelStorage.add_update_tracker(). + + Interacting with an update tracker after it has been removed from the model + will result in an UsedUpdateTrackerAfterRemovalError error. + + Example: + mod = model_storage.ModelStorage() + x = mod.add_variable(0.0, 1.0, True, 'x') + y = mod.add_variable(0.0, 1.0, True, 'y') + tracker = mod.add_update_tracker() + mod.set_variable_ub(x, 3.0) + tracker.export_update() + => "variable_updates: {upper_bounds: {ids: [0], values[3.0] }" + mod.set_variable_ub(y, 2.0) + tracker.export_update() + => "variable_updates: {upper_bounds: {ids: [0, 1], values[3.0, 2.0] }" + tracker.advance_checkpoint() + tracker.export_update() + => "" + mod.set_variable_ub(y, 4.0) + tracker.export_update() + => "variable_updates: {upper_bounds: {ids: [1], values[4.0] }" + tracker.advance_checkpoint() + mod.remove_update_tracker(tracker) + => "" + """ + + @abc.abstractmethod + def export_update(self) -> Optional[model_update_pb2.ModelUpdateProto]: + """Returns changes to the model since last call to checkpoint/creation, or None if no changes occurred.""" + pass + + @abc.abstractmethod + def advance_checkpoint(self) -> None: + """Track changes to the model only after this function call.""" + pass + + +class UsedUpdateTrackerAfterRemovalError(RuntimeError): + def __init__(self): + super().__init__( + "Attempted to use update tracker after removing it from model storage." + ) + + +class BadVariableIdError(LookupError): + """Raised by ModelStorage when a bad variable id is given.""" + + def __init__(self, variable_id): + super().__init__(f"Unexpected variable id: {variable_id}") + self.id = variable_id + + +class BadLinearConstraintIdError(LookupError): + """Raised by ModelStorage when a bad linear constraint id is given.""" + + def __init__(self, linear_constraint_id): + super().__init__(f"Unexpected linear constraint id: {linear_constraint_id}") + self.id = linear_constraint_id + + +class ModelStorage(abc.ABC): + """An interface for in memory storage of an optimization model. + + Most users should not use this class directly and use Model defined in + model.py. + + Stores an mixed integer programming problem of the form: + + {max/min} c*x + d + s.t. lb_c <= A * x <= ub_c + lb_v <= x <= ub_v + x_i integer for i in I + + where x is a vector of n decision variables, d is a number, lb_v, ub_v, and c + are vectors of n numbers, lb_c and ub_c are vectors of m numbers, A is a + m by n matrix, and I is a subset of {1,..., n}. + + Each of the n variables and m constraints have an integer id that you use to + get/set the problem data (c, A, lb_c etc.). Ids begin at zero and increase + sequentially. They are not reused after deletion. Note that if a variable is + deleted, your model has nonconsecutive variable ids. + + For all methods taking an id (e.g. set_variable_lb), providing a bad id + (including the id of a deleted variable) will raise a BadVariableIdError or + BadLinearConstraintIdError. Further, the ModelStorage instance is assumed to + be in a bad state after any such error and there are no guarantees on further + interactions. + + All implementations must have a constructor taking a str argument for the + model name with a default value of the empty string. + + Any ModelStorage can be exported to model_pb2.ModelProto, the format consumed + by MathOpt solvers. Changes to a model can be exported to a + model_update_pb2.ModelUpdateProto with an UpdateTracker, see the UpdateTracker + documentation for details. + + When solving this optimization problem we will additionally require that: + * No numbers are NaN, + * c, d, and A are all finite, + * lb_c and lb_v are not +inf, + * ub_c and ub_v are not -inf, + but those assumptions are not checked or enforced here (NaNs and infinite + values can be used anywhere). + """ + + @property + @abc.abstractmethod + def name(self) -> str: + pass + + @abc.abstractmethod + def add_variable(self, lb: float, ub: float, is_integer: bool, name: str) -> int: + pass + + @abc.abstractmethod + def delete_variable(self, variable_id: int) -> None: + pass + + @abc.abstractmethod + def variable_exists(self, variable_id: int) -> bool: + pass + + @abc.abstractmethod + def next_variable_id(self) -> int: + pass + + @abc.abstractmethod + def set_variable_lb(self, variable_id: int, lb: float) -> None: + pass + + @abc.abstractmethod + def set_variable_ub(self, variable_id: int, ub: float) -> None: + pass + + @abc.abstractmethod + def set_variable_is_integer(self, variable_id: int, is_integer: bool) -> None: + pass + + @abc.abstractmethod + def get_variable_lb(self, variable_id: int) -> float: + pass + + @abc.abstractmethod + def get_variable_ub(self, variable_id: int) -> float: + pass + + @abc.abstractmethod + def get_variable_is_integer(self, variable_id: int) -> bool: + pass + + @abc.abstractmethod + def get_variable_name(self, variable_id: int) -> str: + pass + + @abc.abstractmethod + def get_variables(self) -> Iterator[int]: + """Yields the variable ids in order of creation.""" + pass + + @abc.abstractmethod + def add_linear_constraint(self, lb: float, ub: float, name: str) -> int: + pass + + @abc.abstractmethod + def delete_linear_constraint(self, linear_constraint_id: int) -> None: + pass + + @abc.abstractmethod + def linear_constraint_exists(self, linear_constraint_id: int) -> bool: + pass + + @abc.abstractmethod + def next_linear_constraint_id(self) -> int: + pass + + @abc.abstractmethod + def set_linear_constraint_lb(self, linear_constraint_id: int, lb: float) -> None: + pass + + @abc.abstractmethod + def set_linear_constraint_ub(self, linear_constraint_id: int, ub: float) -> None: + pass + + @abc.abstractmethod + def get_linear_constraint_lb(self, linear_constraint_id: int) -> float: + pass + + @abc.abstractmethod + def get_linear_constraint_ub(self, linear_constraint_id: int) -> float: + pass + + @abc.abstractmethod + def get_linear_constraint_name(self, linear_constraint_id: int) -> str: + pass + + @abc.abstractmethod + def get_linear_constraints(self) -> Iterator[int]: + """Yields the linear constraint ids in order of creation.""" + pass + + @abc.abstractmethod + def set_linear_constraint_coefficient( + self, linear_constraint_id: int, variable_id: int, lb: float + ) -> None: + pass + + @abc.abstractmethod + def get_linear_constraint_coefficient( + self, linear_constraint_id: int, variable_id: int + ) -> float: + pass + + @abc.abstractmethod + def get_linear_constraints_with_variable(self, variable_id: int) -> Iterator[int]: + """Yields the linear constraints with nonzero coefficient for a variable in undefined order.""" + pass + + @abc.abstractmethod + def get_variables_for_linear_constraint( + self, linear_constraint_id: int + ) -> Iterator[int]: + """Yields the variables with nonzero coefficient in a linear constraint in undefined order.""" + pass + + @abc.abstractmethod + def get_linear_constraint_matrix_entries( + self, + ) -> Iterator[LinearConstraintMatrixIdEntry]: + """Yields the nonzero elements of the linear constraint matrix in undefined order.""" + pass + + @abc.abstractmethod + def clear_objective(self) -> None: + """Clears objective coefficients and offset. Does not change direction.""" + + @abc.abstractmethod + def set_linear_objective_coefficient(self, variable_id: int, value: float) -> None: + pass + + @abc.abstractmethod + def get_linear_objective_coefficient(self, variable_id: int) -> float: + pass + + @abc.abstractmethod + def get_linear_objective_coefficients(self) -> Iterator[LinearObjectiveEntry]: + """Yields the nonzero linear objective terms in undefined order.""" + pass + + @abc.abstractmethod + def set_quadratic_objective_coefficient( + self, first_variable_id: int, second_variable_id: int, value: float + ) -> None: + """Sets the objective coefficient for the product of two variables. + + The ordering of the input variables does not matter. + + Args: + first_variable_id: The first variable in the product. + second_variable_id: The second variable in the product. + value: The value of the coefficient. + + Raises: + BadVariableIdError if first_variable_id or second_variable_id are not in + the model. + """ + + @abc.abstractmethod + def get_quadratic_objective_coefficient( + self, first_variable_id: int, second_variable_id: int + ) -> float: + """Gets the objective coefficient for the product of two variables. + + The ordering of the input variables does not matter. + + Args: + first_variable_id: The first variable in the product. + second_variable_id: The second variable in the product. + + Raises: + BadVariableIdError if first_variable_id or second_variable_id are not in + the model. + + Returns: + The value of the coefficient. + """ + + @abc.abstractmethod + def get_quadratic_objective_coefficients(self) -> Iterator[QuadraticEntry]: + """Yields the nonzero quadratic objective terms in undefined order.""" + + @abc.abstractmethod + def get_quadratic_objective_adjacent_variables( + self, variable_id: int + ) -> Iterator[int]: + """Yields the variables multiplying a variable in the objective function. + + Variables are returned in an unspecified order. + + For example, if variables x and y have ids 0 and 1 respectively, and the + quadratic portion of the objective is x^2 + 2 x*y, then + get_quadratic_objective_adjacent_variables(0) = (0, 1). + + Args: + variable_id: Function yields the variables multiplying variable_id in the + objective function. + + Yields: + The variables multiplying variable_id in the objective function. + + Raises: + BadVariableIdError if variable_id is not in the model. + """ + + @abc.abstractmethod + def set_is_maximize(self, is_maximize: bool) -> None: + pass + + @abc.abstractmethod + def get_is_maximize(self) -> bool: + pass + + @abc.abstractmethod + def set_objective_offset(self, offset: float) -> None: + pass + + @abc.abstractmethod + def get_objective_offset(self) -> float: + pass + + @abc.abstractmethod + def export_model(self) -> model_pb2.ModelProto: + pass + + @abc.abstractmethod + def add_update_tracker(self) -> StorageUpdateTracker: + """Creates a StorageUpdateTracker registered with self to view model changes.""" + pass + + @abc.abstractmethod + def remove_update_tracker(self, tracker: StorageUpdateTracker): + """Stops tracker from getting updates on model changes in self. + + An error will be raised if tracker is not a StorageUpdateTracker created by + this Model that has not previously been removed. + + Using an UpdateTracker (via checkpoint or export_update) after it has been + removed will result in an error. + + Args: + tracker: The StorageUpdateTracker to unregister. + + Raises: + KeyError: The tracker was created by another model or was already removed. + """ + pass + + +ModelStorageImpl = TypeVar("ModelStorageImpl", bound=ModelStorage) +ModelStorageImplClass = Type[ModelStorageImpl] diff --git a/ortools/math_opt/python/model_storage_test.py b/ortools/math_opt/python/model_storage_test.py new file mode 100644 index 0000000000..a5e39dd288 --- /dev/null +++ b/ortools/math_opt/python/model_storage_test.py @@ -0,0 +1,928 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math +from typing import Any, Callable + +import unittest +from google3.testing.pybase import parameterized +from ortools.math_opt import model_pb2 +from ortools.math_opt import sparse_containers_pb2 +from ortools.math_opt.python import hash_model_storage +from ortools.math_opt.python import model_storage +from ortools.math_opt.python.testing import compare_proto + +_StorageClass = model_storage.ModelStorageImplClass +_MatEntry = model_storage.LinearConstraintMatrixIdEntry +_ObjEntry = model_storage.LinearObjectiveEntry + + +@parameterized.parameters((hash_model_storage.HashModelStorage,)) +class ModelStorageTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase): + def test_add_and_read_variables(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + self.assertEqual(0, storage.next_variable_id()) + v1 = storage.add_variable(-1.0, 2.5, True, "x") + v2 = storage.add_variable(-math.inf, math.inf, False, "") + self.assertEqual("test_model", storage.name) + + self.assertEqual(-1.0, storage.get_variable_lb(v1)) + self.assertEqual(2.5, storage.get_variable_ub(v1)) + self.assertTrue(storage.get_variable_is_integer(v1)) + self.assertEqual("x", storage.get_variable_name(v1)) + self.assertEqual(0, v1) + self.assertTrue(storage.variable_exists(v1)) + + self.assertEqual(-math.inf, storage.get_variable_lb(v2)) + self.assertEqual(math.inf, storage.get_variable_ub(v2)) + self.assertFalse(storage.get_variable_is_integer(v2)) + self.assertEqual("", storage.get_variable_name(v2)) + self.assertEqual(1, v2) + self.assertTrue(storage.variable_exists(v2)) + + self.assertFalse(storage.variable_exists(max(v1, v2) + 1)) + self.assertListEqual([v1, v2], list(storage.get_variables())) + self.assertEqual(2, storage.next_variable_id()) + + def test_set_variable_lb(self, storage_class: _StorageClass) -> None: + storage = storage_class() + v1 = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_variable_lb(v1, -5.5) + self.assertEqual(-5.5, storage.get_variable_lb(v1)) + + def test_set_variable_ub(self, storage_class: _StorageClass) -> None: + storage = storage_class() + v1 = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_variable_ub(v1, 1.2) + self.assertEqual(1.2, storage.get_variable_ub(v1)) + + def test_set_variable_is_integer(self, storage_class: _StorageClass) -> None: + storage = storage_class() + v1 = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_variable_is_integer(v1, False) + self.assertFalse(storage.get_variable_is_integer(v1)) + + def test_add_and_read_linear_constraints( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + self.assertEqual(0, storage.next_linear_constraint_id()) + c1 = storage.add_linear_constraint(-1.0, 2.5, "c") + c2 = storage.add_linear_constraint(-math.inf, math.inf, "") + + self.assertEqual(-1.0, storage.get_linear_constraint_lb(c1)) + self.assertEqual(2.5, storage.get_linear_constraint_ub(c1)) + self.assertEqual("c", storage.get_linear_constraint_name(c1)) + self.assertEqual(0, c1) + self.assertTrue(storage.linear_constraint_exists(c1)) + + self.assertEqual(-math.inf, storage.get_linear_constraint_lb(c2)) + self.assertEqual(math.inf, storage.get_linear_constraint_ub(c2)) + self.assertEqual("", storage.get_linear_constraint_name(c2)) + self.assertEqual(1, c2) + self.assertTrue(storage.linear_constraint_exists(c2)) + + self.assertListEqual([c1, c2], list(storage.get_linear_constraints())) + self.assertFalse(storage.linear_constraint_exists(1 + max(c1, c2))) + self.assertEqual(2, storage.next_linear_constraint_id()) + + def test_set_linear_constraint_lb(self, storage_class: _StorageClass) -> None: + storage = storage_class() + c1 = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_lb(c1, -5.5) + self.assertEqual(-5.5, storage.get_linear_constraint_lb(c1)) + + def test_set_linear_constraint_ub(self, storage_class: _StorageClass) -> None: + storage = storage_class() + c1 = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_ub(c1, 1.2) + self.assertEqual(1.2, storage.get_linear_constraint_ub(c1)) + + def test_delete_variable_get_other(self, storage_class: _StorageClass) -> None: + storage = storage_class() + v1 = storage.add_variable(-1.0, 2.5, True, "x") + v2 = storage.add_variable(-3.0, 4.5, False, "y") + storage.delete_variable(v1) + self.assertEqual(-3.0, storage.get_variable_lb(v2)) + self.assertEqual(4.5, storage.get_variable_ub(v2)) + self.assertFalse(storage.get_variable_is_integer(v2)) + self.assertEqual("y", storage.get_variable_name(v2)) + self.assertEqual(1, v2) + self.assertFalse(storage.variable_exists(v1)) + self.assertTrue(storage.variable_exists(v2)) + + self.assertListEqual([v2], list(storage.get_variables())) + + def test_double_variable_delete(self, storage_class: _StorageClass) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.delete_variable(x) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.delete_variable(x) + self.assertEqual(x, cm.exception.id) + + def _deleted_variable_invoke_lookup( + self, + storage_class: _StorageClass, + getter: Callable[[model_storage.ModelStorage, int], Any], + ) -> None: + storage = storage_class() + v1 = storage.add_variable(-1.0, 2.5, True, "x") + storage.delete_variable(v1) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + getter(storage, v1) + self.assertEqual(v1, cm.exception.id) + + def test_delete_variable_lb_error(self, storage_class: _StorageClass) -> None: + self._deleted_variable_invoke_lookup( + storage_class, storage_class.get_variable_lb + ) + + def test_delete_variable_ub_error(self, storage_class: _StorageClass) -> None: + self._deleted_variable_invoke_lookup( + storage_class, storage_class.get_variable_ub + ) + + def test_delete_variable_is_integer_error( + self, storage_class: _StorageClass + ) -> None: + self._deleted_variable_invoke_lookup( + storage_class, storage_class.get_variable_is_integer + ) + + def test_delete_variable_name_error(self, storage_class: _StorageClass) -> None: + self._deleted_variable_invoke_lookup( + storage_class, storage_class.get_variable_name + ) + + def test_delete_variable_set_lb_error(self, storage_class: _StorageClass) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.delete_variable(x) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.set_variable_lb(x, -2.0) + self.assertEqual(x, cm.exception.id) + + def test_delete_variable_set_ub_error(self, storage_class: _StorageClass) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.delete_variable(x) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.set_variable_ub(x, 12.0) + self.assertEqual(x, cm.exception.id) + + def test_delete_variable_set_integer_error( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.delete_variable(x) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.set_variable_is_integer(x, False) + self.assertEqual(x, cm.exception.id) + + def test_delete_linear_constraint_get_other( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + c1 = storage.add_linear_constraint(-1.0, 2.5, "c1") + c2 = storage.add_linear_constraint(-math.inf, 5.0, "c2") + storage.delete_linear_constraint(c1) + self.assertEqual(-math.inf, storage.get_linear_constraint_lb(c2)) + self.assertEqual(5.0, storage.get_linear_constraint_ub(c2)) + self.assertEqual("c2", storage.get_linear_constraint_name(c2)) + self.assertEqual(1, c2) + + self.assertListEqual([c2], list(storage.get_linear_constraints())) + + def test_double_linear_constraint_delete( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.delete_linear_constraint(c) + with self.assertRaises(model_storage.BadLinearConstraintIdError) as cm: + storage.delete_linear_constraint(c) + self.assertEqual(c, cm.exception.id) + + def _deleted_linear_constraint_invoke_lookup( + self, + storage_class: _StorageClass, + getter: Callable[[model_storage.ModelStorage, int], Any], + ) -> None: + storage = storage_class() + c1 = storage.add_linear_constraint(-1.0, 2.5, "c1") + storage.delete_linear_constraint(c1) + with self.assertRaises(model_storage.BadLinearConstraintIdError) as cm: + getter(storage, c1) + self.assertEqual(c1, cm.exception.id) + + def test_delete_linear_constraint_lb_error( + self, storage_class: _StorageClass + ) -> None: + self._deleted_linear_constraint_invoke_lookup( + storage_class, storage_class.get_linear_constraint_lb + ) + + def test_delete_linear_constraint_ub_error( + self, storage_class: _StorageClass + ) -> None: + self._deleted_linear_constraint_invoke_lookup( + storage_class, storage_class.get_linear_constraint_ub + ) + + def test_delete_linear_constraint_name_error( + self, storage_class: _StorageClass + ) -> None: + self._deleted_linear_constraint_invoke_lookup( + storage_class, storage_class.get_linear_constraint_name + ) + + def test_delete_linear_constraint_set_lb_error( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.delete_linear_constraint(c) + with self.assertRaises(model_storage.BadLinearConstraintIdError) as cm: + storage.set_linear_constraint_lb(c, -2.0) + self.assertEqual(c, cm.exception.id) + + def test_delete_linear_constraint_set_ub_error( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.delete_linear_constraint(c) + with self.assertRaises(model_storage.BadLinearConstraintIdError) as cm: + storage.set_linear_constraint_ub(c, 12.0) + self.assertEqual(c, cm.exception.id) + + def test_objective_offset(self, storage_class: _StorageClass) -> None: + storage = storage_class() + self.assertEqual(0.0, storage.get_objective_offset()) + storage.set_objective_offset(1.5) + self.assertEqual(1.5, storage.get_objective_offset()) + + def test_objective_direction(self, storage_class: _StorageClass) -> None: + storage = storage_class() + self.assertFalse(storage.get_is_maximize()) + storage.set_is_maximize(True) + self.assertTrue(storage.get_is_maximize()) + storage.set_is_maximize(False) + self.assertFalse(storage.get_is_maximize()) + + def test_set_linear_objective_coefficient( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(0.0, 1.0, False, "y") + z = storage.add_variable(0.0, 1.0, True, "z") + storage.set_linear_objective_coefficient(x, 2.0) + storage.set_linear_objective_coefficient(z, -5.5) + self.assertEqual(2.0, storage.get_linear_objective_coefficient(x)) + self.assertEqual(0.0, storage.get_linear_objective_coefficient(y)) + self.assertEqual(-5.5, storage.get_linear_objective_coefficient(z)) + + self.assertCountEqual( + [ + _ObjEntry(variable_id=x, coefficient=2.0), + _ObjEntry(variable_id=z, coefficient=-5.5), + ], + storage.get_linear_objective_coefficients(), + ) + + def test_clear_linear_objective_coefficient( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(0.0, 1.0, False, "y") + z = storage.add_variable(0.0, 1.0, True, "z") + storage.set_linear_objective_coefficient(x, 2.0) + storage.set_linear_objective_coefficient(z, -5.5) + storage.set_objective_offset(1.0) + self.assertEqual(2.0, storage.get_linear_objective_coefficient(x)) + self.assertEqual(0.0, storage.get_linear_objective_coefficient(y)) + self.assertEqual(-5.5, storage.get_linear_objective_coefficient(z)) + self.assertEqual(1.0, storage.get_objective_offset()) + storage.clear_objective() + self.assertEqual(0.0, storage.get_linear_objective_coefficient(x)) + self.assertEqual(0.0, storage.get_linear_objective_coefficient(y)) + self.assertEqual(0.0, storage.get_linear_objective_coefficient(z)) + self.assertEqual(0.0, storage.get_objective_offset()) + + def test_set_linear_objective_coefficient_bad_id( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.set_linear_objective_coefficient(x + 1, 2.0) + self.assertEqual(x + 1, cm.exception.id) + + def test_set_linear_objective_coefficient_deleted_id( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_linear_objective_coefficient(y, 3.0) + storage.delete_variable(x) + self.assertEqual(3.0, storage.get_linear_objective_coefficient(y)) + self.assertCountEqual( + [model_storage.LinearObjectiveEntry(variable_id=y, coefficient=3.0)], + storage.get_linear_objective_coefficients(), + ) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.set_linear_objective_coefficient(x, 2.0) + self.assertEqual(x, cm.exception.id) + + def test_get_linear_objective_coefficient_deleted_nonzero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_linear_objective_coefficient(x, 1.0) + storage.set_linear_objective_coefficient(y, 3.0) + storage.delete_variable(x) + self.assertEqual(3.0, storage.get_linear_objective_coefficient(y)) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.get_linear_objective_coefficient(x) + self.assertEqual(x, cm.exception.id) + + def test_set_quadratic_objective_coefficient( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(0.0, 1.0, False, "y") + z = storage.add_variable(0.0, 1.0, True, "z") + storage.set_quadratic_objective_coefficient(x, y, 2.0) + storage.set_quadratic_objective_coefficient(z, z, -5.5) + storage.set_quadratic_objective_coefficient(z, y, 1.5) + self.assertEqual(2.0, storage.get_quadratic_objective_coefficient(x, y)) + self.assertEqual(0.0, storage.get_quadratic_objective_coefficient(y, y)) + self.assertEqual(-5.5, storage.get_quadratic_objective_coefficient(z, z)) + self.assertEqual(1.5, storage.get_quadratic_objective_coefficient(y, z)) + + self.assertCountEqual( + [ + model_storage.QuadraticEntry( + id_key=model_storage.QuadraticTermIdKey(x, y), coefficient=2.0 + ), + model_storage.QuadraticEntry( + id_key=model_storage.QuadraticTermIdKey(z, z), coefficient=-5.5 + ), + model_storage.QuadraticEntry( + id_key=model_storage.QuadraticTermIdKey(y, z), coefficient=1.5 + ), + ], + storage.get_quadratic_objective_coefficients(), + ) + + self.assertCountEqual( + [y], storage.get_quadratic_objective_adjacent_variables(x) + ) + self.assertCountEqual( + [x, z], storage.get_quadratic_objective_adjacent_variables(y) + ) + self.assertCountEqual( + [y, z], storage.get_quadratic_objective_adjacent_variables(z) + ) + + def test_clear_quadratic_objective_coefficient( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(0.0, 1.0, False, "y") + z = storage.add_variable(0.0, 1.0, True, "z") + storage.set_linear_objective_coefficient(x, 2.0) + storage.set_linear_objective_coefficient(z, -5.5) + storage.set_quadratic_objective_coefficient(x, y, 2.0) + storage.set_quadratic_objective_coefficient(z, z, -5.5) + storage.set_quadratic_objective_coefficient(z, y, 1.5) + storage.set_objective_offset(1.0) + storage.clear_objective() + self.assertEqual(0.0, storage.get_linear_objective_coefficient(x)) + self.assertEqual(0.0, storage.get_linear_objective_coefficient(y)) + self.assertEqual(0.0, storage.get_linear_objective_coefficient(z)) + self.assertEqual(0.0, storage.get_quadratic_objective_coefficient(x, y)) + self.assertEqual(0.0, storage.get_quadratic_objective_coefficient(y, y)) + self.assertEqual(0.0, storage.get_quadratic_objective_coefficient(z, z)) + self.assertEqual(0.0, storage.get_quadratic_objective_coefficient(y, z)) + self.assertEqual(0.0, storage.get_objective_offset()) + self.assertEmpty(list(storage.get_quadratic_objective_adjacent_variables(x))) + self.assertEmpty(list(storage.get_quadratic_objective_adjacent_variables(y))) + self.assertEmpty(list(storage.get_quadratic_objective_adjacent_variables(z))) + + def test_set_quadratic_objective_coefficient_bad_id( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.set_quadratic_objective_coefficient(x, x + 1, 2.0) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.set_quadratic_objective_coefficient(x + 1, x, 2.0) + self.assertEqual(x + 1, cm.exception.id) + + def test_get_quadratic_objective_coefficient_bad_id( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.get_quadratic_objective_coefficient(x, x + 1) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.get_quadratic_objective_coefficient(x + 1, x) + self.assertEqual(x + 1, cm.exception.id) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + list(storage.get_quadratic_objective_adjacent_variables(x + 1)) + self.assertEqual(x + 1, cm.exception.id) + + def test_set_quadratic_objective_coefficient_existing_to_zero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, x, -1.0) + storage.set_quadratic_objective_coefficient(x, y, 1.0) + storage.set_quadratic_objective_coefficient(y, y, 3.0) + + storage.set_quadratic_objective_coefficient(x, x, 0.0) + storage.set_quadratic_objective_coefficient(x, y, 0.0) + self.assertEqual(0.0, storage.get_quadratic_objective_coefficient(x, x)) + self.assertEqual(0.0, storage.get_quadratic_objective_coefficient(x, y)) + self.assertEqual(3.0, storage.get_quadratic_objective_coefficient(y, y)) + self.assertCountEqual( + [y], storage.get_quadratic_objective_adjacent_variables(y) + ) + self.assertEmpty(list(storage.get_quadratic_objective_adjacent_variables(x))) + + def test_set_quadratic_objective_coefficient_deleted_id( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, y, 1.0) + storage.set_quadratic_objective_coefficient(y, y, 3.0) + storage.delete_variable(x) + self.assertEqual(3.0, storage.get_quadratic_objective_coefficient(y, y)) + self.assertCountEqual( + [y], storage.get_quadratic_objective_adjacent_variables(y) + ) + + def test_set_quadratic_objective_coefficient_deleted_id_get_coeff_error( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, y, 1.0) + storage.set_quadratic_objective_coefficient(y, y, 3.0) + storage.delete_variable(x) + + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.get_quadratic_objective_coefficient(x, y) + self.assertEqual(x, cm.exception.id) + + def test_set_quadratic_objective_coefficient_deleted_id_set_coeff_error( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, y, 1.0) + storage.set_quadratic_objective_coefficient(y, y, 3.0) + storage.delete_variable(x) + + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.set_quadratic_objective_coefficient(x, y, 1.0) + self.assertEqual(x, cm.exception.id) + + def test_set_quadratic_objective_coefficient_deleted_id_adjacent_error( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, y, 1.0) + storage.set_quadratic_objective_coefficient(y, y, 3.0) + storage.delete_variable(x) + + with self.assertRaises(model_storage.BadVariableIdError) as cm: + list(storage.get_quadratic_objective_adjacent_variables(x)) + self.assertEqual(x, cm.exception.id) + + def test_constraint_matrix(self, storage_class: _StorageClass) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, False, "y") + z = storage.add_variable(0.0, 1.0, True, "z") + c = storage.add_linear_constraint(-math.inf, 3.0, "c") + d = storage.add_linear_constraint(-math.inf, 1.0, "d") + storage.set_linear_constraint_coefficient(c, y, 1.0) + storage.set_linear_constraint_coefficient(d, x, 2.0) + storage.set_linear_constraint_coefficient(d, y, -1.0) + storage.set_linear_constraint_coefficient(d, z, 1.0) + storage.set_linear_constraint_coefficient(d, z, 0.0) + + self.assertEqual(0.0, storage.get_linear_constraint_coefficient(c, x)) + self.assertEqual(1.0, storage.get_linear_constraint_coefficient(c, y)) + self.assertEqual(0.0, storage.get_linear_constraint_coefficient(c, z)) + + self.assertEqual(2.0, storage.get_linear_constraint_coefficient(d, x)) + self.assertEqual(-1.0, storage.get_linear_constraint_coefficient(d, y)) + self.assertEqual(0.0, storage.get_linear_constraint_coefficient(d, z)) + + self.assertCountEqual([y], storage.get_variables_for_linear_constraint(c)) + self.assertCountEqual([x, y], storage.get_variables_for_linear_constraint(d)) + + self.assertCountEqual([d], storage.get_linear_constraints_with_variable(x)) + self.assertCountEqual([c, d], storage.get_linear_constraints_with_variable(y)) + self.assertCountEqual([], storage.get_linear_constraints_with_variable(z)) + + self.assertCountEqual( + [ + _MatEntry(linear_constraint_id=c, variable_id=y, coefficient=1.0), + _MatEntry(linear_constraint_id=d, variable_id=x, coefficient=2.0), + _MatEntry(linear_constraint_id=d, variable_id=y, coefficient=-1.0), + ], + storage.get_linear_constraint_matrix_entries(), + ) + + def test_constraint_matrix_zero_unset_entry( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-math.inf, 3.0, "c") + storage.set_linear_constraint_coefficient(c, x, 0.0) + self.assertEmpty(list(storage.get_linear_objective_coefficients())) + self.assertEmpty(list(storage.get_variables_for_linear_constraint(c))) + self.assertEmpty(list(storage.get_linear_constraints_with_variable(x))) + self.assertEqual(0.0, storage.get_linear_constraint_coefficient(c, x)) + + def test_constraint_matrix_with_deletion( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, False, "y") + z = storage.add_variable(0.0, 1.0, True, "z") + c = storage.add_linear_constraint(-math.inf, 3.0, "c") + d = storage.add_linear_constraint(-math.inf, 1.0, "d") + storage.set_linear_constraint_coefficient(c, y, 1.0) + storage.set_linear_constraint_coefficient(d, x, 2.0) + storage.set_linear_constraint_coefficient(d, y, -1.0) + storage.set_linear_constraint_coefficient(c, z, 1.0) + + storage.delete_variable(y) + storage.delete_linear_constraint(c) + + self.assertEqual(2.0, storage.get_linear_constraint_coefficient(d, x)) + self.assertEqual(0.0, storage.get_linear_constraint_coefficient(d, z)) + + self.assertCountEqual([x], storage.get_variables_for_linear_constraint(d)) + + self.assertCountEqual([d], storage.get_linear_constraints_with_variable(x)) + self.assertCountEqual([], storage.get_linear_constraints_with_variable(z)) + + self.assertCountEqual( + [_MatEntry(linear_constraint_id=d, variable_id=x, coefficient=2.0)], + storage.get_linear_constraint_matrix_entries(), + ) + + def test_variables_for_linear_constraint_deleted_error( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-math.inf, 3.0, "c") + storage.set_linear_constraint_coefficient(c, x, 1.0) + storage.delete_linear_constraint(c) + with self.assertRaises(model_storage.BadLinearConstraintIdError) as cm: + list(storage.get_variables_for_linear_constraint(c)) + self.assertEqual(c, cm.exception.id) + + def test_linear_constraints_with_variable_deleted_error( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-math.inf, 3.0, "c") + storage.set_linear_constraint_coefficient(c, x, 1.0) + storage.delete_variable(x) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + list(storage.get_linear_constraints_with_variable(x)) + self.assertEqual(x, cm.exception.id) + + def test_constraint_matrix_set_deleted_var( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-math.inf, 3.0, "c") + storage.delete_variable(x) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.set_linear_constraint_coefficient(c, x, 2.0) + self.assertEqual(x, cm.exception.id) + + def test_constraint_matrix_get_deleted_var( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-math.inf, 3.0, "c") + storage.delete_variable(x) + with self.assertRaises(model_storage.BadVariableIdError) as cm: + storage.get_linear_constraint_coefficient(c, x) + self.assertEqual(x, cm.exception.id) + + def test_constraint_matrix_set_deleted_constraint( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-math.inf, 3.0, "c") + storage.delete_linear_constraint(c) + with self.assertRaises(model_storage.BadLinearConstraintIdError) as cm: + storage.set_linear_constraint_coefficient(c, x, 2.0) + self.assertEqual(c, cm.exception.id) + + def test_constraint_matrix_get_deleted_constraint( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-math.inf, 3.0, "c") + storage.delete_linear_constraint(c) + with self.assertRaises(model_storage.BadLinearConstraintIdError) as cm: + storage.get_linear_constraint_coefficient(c, x) + self.assertEqual(c, cm.exception.id) + + def test_proto_export(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, False, "") + z = storage.add_variable(0.0, 1.0, True, "z") + c = storage.add_linear_constraint(-math.inf, 3.0, "") + d = storage.add_linear_constraint(0.0, 1.0, "d") + storage.set_linear_constraint_coefficient(c, y, 1.0) + storage.set_linear_constraint_coefficient(d, x, 2.0) + storage.set_linear_constraint_coefficient(d, y, -1.0) + storage.set_linear_constraint_coefficient(d, z, 1.0) + storage.set_linear_constraint_coefficient(d, z, 0.0) + storage.set_linear_objective_coefficient(x, 2.5) + storage.set_linear_objective_coefficient(z, -1.0) + storage.set_quadratic_objective_coefficient(x, x, 3.0) + storage.set_quadratic_objective_coefficient(x, y, 4.0) + storage.set_quadratic_objective_coefficient(x, z, 5.0) + storage.set_is_maximize(True) + storage.set_objective_offset(7.0) + + expected = model_pb2.ModelProto( + name="test_model", + variables=model_pb2.VariablesProto( + ids=[0, 1, 2], + lower_bounds=[-1.0, -1.0, 0.0], + upper_bounds=[2.5, 2.5, 1.0], + integers=[True, False, True], + names=["x", "", "z"], + ), + linear_constraints=model_pb2.LinearConstraintsProto( + ids=[0, 1], + lower_bounds=[-math.inf, 0.0], + upper_bounds=[3.0, 1.0], + names=["", "d"], + ), + objective=model_pb2.ObjectiveProto( + maximize=True, + offset=7.0, + linear_coefficients=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 2], values=[2.5, -1.0] + ), + quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[0, 0, 0], + column_ids=[0, 1, 2], + coefficients=[3.0, 4.0, 5.0], + ), + ), + linear_constraint_matrix=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[0, 1, 1], + column_ids=[1, 0, 1], + coefficients=[1.0, 2.0, -1.0], + ), + ) + self.assert_protos_equiv(expected, storage.export_model()) + + def test_proto_export_with_deletes(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, False, "") + z = storage.add_variable(0.0, 1.0, True, "z") + c = storage.add_linear_constraint(-math.inf, 3.0, "") + d = storage.add_linear_constraint(0.0, 1.0, "d") + storage.set_linear_constraint_coefficient(c, y, 1.0) + storage.set_linear_constraint_coefficient(d, x, 2.0) + storage.set_linear_constraint_coefficient(d, y, -1.0) + storage.set_linear_constraint_coefficient(d, z, 1.0) + storage.set_linear_constraint_coefficient(d, z, 0.0) + storage.set_linear_objective_coefficient(x, 2.5) + storage.set_quadratic_objective_coefficient(x, x, 3.0) + storage.set_quadratic_objective_coefficient(x, y, 4.0) + storage.set_quadratic_objective_coefficient(x, z, 5.0) + storage.set_is_maximize(False) + storage.delete_variable(y) + storage.delete_linear_constraint(c) + + expected = model_pb2.ModelProto( + name="test_model", + variables=model_pb2.VariablesProto( + ids=[0, 2], + lower_bounds=[-1.0, 0.0], + upper_bounds=[2.5, 1.0], + integers=[True, True], + names=["x", "z"], + ), + linear_constraints=model_pb2.LinearConstraintsProto( + ids=[1], lower_bounds=[0.0], upper_bounds=[1.0], names=["d"] + ), + objective=model_pb2.ObjectiveProto( + maximize=False, + offset=0.0, + linear_coefficients=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[2.5] + ), + quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[0, 0], column_ids=[0, 2], coefficients=[3.0, 5.0] + ), + ), + linear_constraint_matrix=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[1], column_ids=[0], coefficients=[2.0] + ), + ) + self.assert_protos_equiv(expected, storage.export_model()) + + def test_proto_export_empty(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + expected = model_pb2.ModelProto(name="test_model") + self.assert_protos_equiv(expected, storage.export_model()) + + def test_proto_export_feasibility(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + storage.add_variable(-1.0, 2.5, True, "x") + expected = model_pb2.ModelProto( + name="test_model", + variables=model_pb2.VariablesProto( + ids=[0], + lower_bounds=[-1.0], + upper_bounds=[2.5], + integers=[True], + names=["x"], + ), + ) + self.assert_protos_equiv(expected, storage.export_model()) + + def test_proto_export_empty_names(self, storage_class: _StorageClass) -> None: + storage = storage_class("") + storage.add_variable(-1.0, 2.5, True, "") + storage.add_linear_constraint(0.0, 1.0, "") + expected = model_pb2.ModelProto( + variables=model_pb2.VariablesProto( + ids=[0], + lower_bounds=[-1.0], + upper_bounds=[2.5], + integers=[True], + # NOTE: names is the empty list not a list with an empty string. + names=[], + ), + linear_constraints=model_pb2.LinearConstraintsProto( + ids=[0], + lower_bounds=[0.0], + upper_bounds=[1.0], + # NOTE: names is the empty list not a list with an empty string. + names=[], + ), + ) + self.assert_protos_equiv(expected, storage.export_model()) + + def _assert_nan(self, x): + self.assertTrue(math.isnan(x), f"Expected nan, found {x}") + + # Ensure that we don't silently drop NaNs. + def test_nans_pass_through(self, storage_class: _StorageClass) -> None: + storage = storage_class("nan_model") + nan = math.nan + x = storage.add_variable(nan, 2.5, True, "x") + y = storage.add_variable(-1.0, nan, True, "y") + c = storage.add_linear_constraint(nan, math.inf, "c") + d = storage.add_linear_constraint(0.0, nan, "d") + storage.set_objective_offset(nan) + storage.set_linear_objective_coefficient(x, 1.0) + storage.set_linear_objective_coefficient(y, nan) + storage.set_quadratic_objective_coefficient(x, x, 3.0) + storage.set_quadratic_objective_coefficient(x, y, nan) + storage.set_linear_constraint_coefficient(c, x, nan) + storage.set_linear_constraint_coefficient(c, y, 1.0) + storage.set_linear_constraint_coefficient(d, y, nan) + + # Test the getters. + self.assertEqual("nan_model", storage.name) + self._assert_nan(storage.get_objective_offset()) + self._assert_nan(storage.get_variable_lb(x)) + self.assertEqual(2.5, storage.get_variable_ub(x)) + self.assertEqual(-1.0, storage.get_variable_lb(y)) + self._assert_nan(storage.get_variable_ub(y)) + self.assertEqual(1.0, storage.get_linear_objective_coefficient(x)) + self._assert_nan(storage.get_linear_objective_coefficient(y)) + self._assert_nan(storage.get_linear_constraint_lb(c)) + self.assertEqual(math.inf, storage.get_linear_constraint_ub(c)) + self.assertEqual(0.0, storage.get_linear_constraint_lb(d)) + self._assert_nan(storage.get_linear_constraint_ub(d)) + self._assert_nan(storage.get_linear_constraint_coefficient(c, x)) + self.assertEqual(1.0, storage.get_linear_constraint_coefficient(c, y)) + self.assertEqual(0.0, storage.get_linear_constraint_coefficient(d, x)) + self.assertEqual(3.0, storage.get_quadratic_objective_coefficient(x, x)) + self.assertEqual(0.0, storage.get_quadratic_objective_coefficient(y, y)) + self._assert_nan(storage.get_quadratic_objective_coefficient(x, y)) + self._assert_nan(storage.get_linear_constraint_coefficient(d, y)) + + # Test the iterators that interact with the NaN values. + self.assertCountEqual([x, y], storage.get_variables_for_linear_constraint(c)) + self.assertCountEqual([y], storage.get_variables_for_linear_constraint(d)) + + self.assertCountEqual([c], storage.get_linear_constraints_with_variable(x)) + self.assertCountEqual([c, d], storage.get_linear_constraints_with_variable(y)) + + self.assertCountEqual( + [ + _MatEntry(linear_constraint_id=c, variable_id=x, coefficient=nan), + _MatEntry(linear_constraint_id=c, variable_id=y, coefficient=1.0), + _MatEntry(linear_constraint_id=d, variable_id=y, coefficient=nan), + ], + storage.get_linear_constraint_matrix_entries(), + ) + + self.assertCountEqual( + [ + _ObjEntry(variable_id=x, coefficient=1.0), + _ObjEntry(variable_id=y, coefficient=nan), + ], + storage.get_linear_objective_coefficients(), + ) + + # Export to proto + expected = model_pb2.ModelProto( + name="nan_model", + variables=model_pb2.VariablesProto( + ids=[0, 1], + lower_bounds=[nan, -1.0], + upper_bounds=[2.5, nan], + integers=[True, True], + names=["x", "y"], + ), + linear_constraints=model_pb2.LinearConstraintsProto( + ids=[0, 1], + lower_bounds=[nan, 0.0], + upper_bounds=[math.inf, nan], + names=["c", "d"], + ), + objective=model_pb2.ObjectiveProto( + maximize=False, + offset=nan, + linear_coefficients=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1], values=[1.0, nan] + ), + quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[0, 0], column_ids=[0, 1], coefficients=[3.0, nan] + ), + ), + linear_constraint_matrix=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[0, 0, 1], + column_ids=[0, 1, 1], + coefficients=[nan, 1.0, nan], + ), + ) + self.assert_protos_equiv(expected, storage.export_model()) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/model_storage_update_test.py b/ortools/math_opt/python/model_storage_update_test.py new file mode 100644 index 0000000000..837eca5b5c --- /dev/null +++ b/ortools/math_opt/python/model_storage_update_test.py @@ -0,0 +1,1174 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from google3.testing.pybase import parameterized +from ortools.math_opt import model_pb2 +from ortools.math_opt import model_update_pb2 +from ortools.math_opt import sparse_containers_pb2 +from ortools.math_opt.python import hash_model_storage +from ortools.math_opt.python import model_storage +from ortools.math_opt.python.testing import compare_proto + +_StorageClass = model_storage.ModelStorageImplClass + +_ModelUpdateProto = model_update_pb2.ModelUpdateProto +_VariableUpdatesProto = model_update_pb2.VariableUpdatesProto +_LinearConstraintUpdatesProto = model_update_pb2.LinearConstraintUpdatesProto +_SparseDoubleVectorProto = sparse_containers_pb2.SparseDoubleVectorProto +_SparseBoolVectorProto = sparse_containers_pb2.SparseBoolVectorProto +_SparseDoubleMatrixProto = sparse_containers_pb2.SparseDoubleMatrixProto +_VariablesProto = model_pb2.VariablesProto +_LinearConstraintsProto = model_pb2.LinearConstraintsProto +_ObjectiveUpdatesProto = model_update_pb2.ObjectiveUpdatesProto + + +@parameterized.parameters((hash_model_storage.HashModelStorage,)) +class ModelStorageTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase): + def test_simple_delete_var(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + storage.delete_variable(x) + self.assert_protos_equiv( + _ModelUpdateProto(deleted_variable_ids=[0]), tracker.export_update() + ) + + def test_simple_delete_lin_con(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + tracker.advance_checkpoint() + storage.delete_linear_constraint(c) + self.assert_protos_equiv( + _ModelUpdateProto(deleted_linear_constraint_ids=[0]), + tracker.export_update(), + ) + + def test_update_var_lb(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + storage.set_variable_lb(x, -7.0) + self.assert_protos_equiv( + _ModelUpdateProto( + variable_updates=_VariableUpdatesProto( + lower_bounds=_SparseDoubleVectorProto(ids=[0], values=[-7.0]) + ) + ), + tracker.export_update(), + ) + + def test_update_var_lb_same_value(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + storage.set_variable_lb(x, -1.0) + self.assertIsNone(tracker.export_update()) + + def test_update_var_ub(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + storage.set_variable_ub(x, 12.5) + self.assert_protos_equiv( + _ModelUpdateProto( + variable_updates=_VariableUpdatesProto( + upper_bounds=_SparseDoubleVectorProto(ids=[0], values=[12.5]) + ) + ), + tracker.export_update(), + ) + + def test_update_var_ub_same_value(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + storage.set_variable_ub(x, 2.5) + self.assertIsNone(tracker.export_update()) + + def test_update_var_integer(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + storage.set_variable_is_integer(x, False) + self.assert_protos_equiv( + _ModelUpdateProto( + variable_updates=_VariableUpdatesProto( + integers=_SparseBoolVectorProto(ids=[0], values=[False]) + ) + ), + tracker.export_update(), + ) + + def test_update_var_integer_same_value(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + storage.set_variable_is_integer(x, True) + self.assertIsNone(tracker.export_update()) + + def test_update_var_then_delete(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + storage.set_variable_lb(x, -3.0) + storage.set_variable_ub(x, 5.0) + storage.set_variable_is_integer(x, False) + storage.delete_variable(x) + self.assert_protos_equiv( + _ModelUpdateProto(deleted_variable_ids=[0]), tracker.export_update() + ) + + def test_update_lin_con_lb(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + tracker.advance_checkpoint() + storage.set_linear_constraint_lb(c, -7.0) + self.assert_protos_equiv( + _ModelUpdateProto( + linear_constraint_updates=_LinearConstraintUpdatesProto( + lower_bounds=_SparseDoubleVectorProto(ids=[0], values=[-7.0]) + ) + ), + tracker.export_update(), + ) + + def test_update_lin_con_lb_same_value(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + tracker.advance_checkpoint() + storage.set_linear_constraint_lb(c, -1.0) + self.assertIsNone(tracker.export_update()) + + def test_update_lin_con_ub(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + tracker.advance_checkpoint() + storage.set_linear_constraint_ub(c, 12.5) + self.assert_protos_equiv( + _ModelUpdateProto( + linear_constraint_updates=_LinearConstraintUpdatesProto( + upper_bounds=_SparseDoubleVectorProto(ids=[0], values=[12.5]) + ) + ), + tracker.export_update(), + ) + + def test_update_lin_con_ub_same_value(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + tracker.advance_checkpoint() + storage.set_linear_constraint_ub(c, 2.5) + self.assertIsNone(tracker.export_update()) + + def test_update_lin_con_then_delete(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + tracker.advance_checkpoint() + storage.set_linear_constraint_lb(c, -3.0) + storage.set_linear_constraint_ub(c, 5.0) + storage.delete_linear_constraint(c) + self.assert_protos_equiv( + _ModelUpdateProto(deleted_linear_constraint_ids=[0]), + tracker.export_update(), + ) + + def test_new_var(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + storage.add_variable(-1.0, 2.5, True, "x") + expected = _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[0], + lower_bounds=[-1.0], + upper_bounds=[2.5], + integers=[True], + names=["x"], + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_modify_new_var(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_variable_lb(x, -4.0) + storage.set_variable_ub(x, 5.0) + storage.set_variable_is_integer(x, False) + expected = _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[0], + lower_bounds=[-4.0], + upper_bounds=[5.0], + integers=[False], + names=["x"], + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_new_var_with_deletes(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(0.0, 1.0, False, "x") + storage.add_variable(-1.0, 2.5, True, "y") + storage.delete_variable(x) + expected = _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[1], + lower_bounds=[-1.0], + upper_bounds=[2.5], + integers=[True], + names=["y"], + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_delete_var_before_first_update(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + tracker.advance_checkpoint() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.add_variable(-2.0, 3.5, True, "y") + storage.delete_variable(x) + self.assert_protos_equiv( + _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[1], + lower_bounds=[-2.0], + upper_bounds=[3.5], + integers=[True], + names=["y"], + ) + ), + tracker.export_update(), + ) + + def test_new_lin_con(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + storage.add_linear_constraint(-1.0, 2.5, "c") + expected = _ModelUpdateProto( + new_linear_constraints=_LinearConstraintsProto( + ids=[0], lower_bounds=[-1.0], upper_bounds=[2.5], names=["c"] + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_modify_new_lin_con(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_lb(c, -4.0) + storage.set_linear_constraint_ub(c, 5.0) + expected = _ModelUpdateProto( + new_linear_constraints=_LinearConstraintsProto( + ids=[0], lower_bounds=[-4.0], upper_bounds=[5.0], names=["c"] + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_new_lin_con_with_deletes(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + c = storage.add_linear_constraint(0.0, 1.0, "c") + storage.add_linear_constraint(-1.0, 2.5, "d") + storage.delete_linear_constraint(c) + expected = _ModelUpdateProto( + new_linear_constraints=_LinearConstraintsProto( + ids=[1], lower_bounds=[-1.0], upper_bounds=[2.5], names=["d"] + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_delete_lin_con_before_first_update( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + tracker.advance_checkpoint() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.add_linear_constraint(-2.0, 3.5, "d") + storage.delete_linear_constraint(c) + self.assert_protos_equiv( + _ModelUpdateProto( + new_linear_constraints=_LinearConstraintsProto( + ids=[1], lower_bounds=[-2.0], upper_bounds=[3.5], names=["d"] + ) + ), + tracker.export_update(), + ) + + def test_update_objective_direction(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + storage.set_is_maximize(True) + expected = _ModelUpdateProto( + objective_updates=_ObjectiveUpdatesProto(direction_update=True) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_update_objective_direction_same( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + storage.set_is_maximize(False) + self.assertIsNone(tracker.export_update()) + + def test_update_objective_offset(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + storage.set_objective_offset(5.0) + expected = _ModelUpdateProto( + objective_updates=_ObjectiveUpdatesProto(offset_update=5.0) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_update_objective_offset_same(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + storage.set_objective_offset(0.0) + self.assertIsNone(tracker.export_update()) + + def test_objective_update_existing_zero(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + storage.set_linear_objective_coefficient(x, 3.0) + expected = _ModelUpdateProto( + objective_updates=_ObjectiveUpdatesProto( + linear_coefficients=_SparseDoubleVectorProto(ids=[0], values=[3.0]) + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_objective_update_existing_zero_same( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + storage.set_linear_objective_coefficient(x, 0.0) + self.assertIsNone(tracker.export_update()) + + def test_objective_update_existing_nonzero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_linear_objective_coefficient(x, 4.0) + tracker.advance_checkpoint() + storage.set_linear_objective_coefficient(x, 3.0) + expected = _ModelUpdateProto( + objective_updates=_ObjectiveUpdatesProto( + linear_coefficients=_SparseDoubleVectorProto(ids=[0], values=[3.0]) + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_objective_update_existing_nonzero_same( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_linear_objective_coefficient(x, 4.0) + tracker.advance_checkpoint() + storage.set_linear_objective_coefficient(x, 4.0) + self.assertIsNone(tracker.export_update()) + + def test_objective_update_clear(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(0.0, 1.0, False, "y") + z = storage.add_variable(0.0, 1.0, True, "z") + + storage.set_linear_objective_coefficient(x, 2.0) + storage.set_linear_objective_coefficient(z, -5.5) + storage.set_objective_offset(1.0) + self.assertEqual(2.0, storage.get_linear_objective_coefficient(x)) + self.assertEqual(0.0, storage.get_linear_objective_coefficient(y)) + self.assertEqual(-5.5, storage.get_linear_objective_coefficient(z)) + self.assertEqual(1.0, storage.get_objective_offset()) + tracker.advance_checkpoint() + w = storage.add_variable(0.0, 1.0, True, "w") + storage.set_linear_objective_coefficient(w, 1.0) + storage.clear_objective() + expected = _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[3], + lower_bounds=[0.0], + upper_bounds=[1.0], + integers=[True], + names=["w"], + ), + objective_updates=_ObjectiveUpdatesProto( + offset_update=0.0, + linear_coefficients=_SparseDoubleVectorProto( + ids=[x, z], values=[0.0, 0.0] + ), + ), + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_objective_update_existing_to_zero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_linear_objective_coefficient(x, 4.0) + tracker.advance_checkpoint() + storage.set_linear_objective_coefficient(x, 0.0) + expected = _ModelUpdateProto( + objective_updates=_ObjectiveUpdatesProto( + linear_coefficients=_SparseDoubleVectorProto(ids=[0], values=[0.0]) + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_objective_update_existing_then_delete( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_linear_objective_coefficient(x, 4.0) + tracker.advance_checkpoint() + storage.set_linear_objective_coefficient(x, 2.0) + storage.delete_variable(x) + self.assert_protos_equiv( + _ModelUpdateProto(deleted_variable_ids=[0]), tracker.export_update() + ) + + def test_objective_update_new(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_linear_objective_coefficient(x, 4.0) + self.assert_protos_equiv( + _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[0], + lower_bounds=[-1.0], + upper_bounds=[2.5], + integers=[True], + names=["x"], + ), + objective_updates=_ObjectiveUpdatesProto( + linear_coefficients=_SparseDoubleVectorProto(ids=[0], values=[4.0]) + ), + ), + tracker.export_update(), + ) + + def test_objective_update_new_zero(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_linear_objective_coefficient(x, 4.0) + storage.set_linear_objective_coefficient(x, 0.0) + self.assert_protos_equiv( + _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[0], + lower_bounds=[-1.0], + upper_bounds=[2.5], + integers=[True], + names=["x"], + ) + ), + tracker.export_update(), + ) + + def test_objective_update_new_then_delete( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_linear_objective_coefficient(x, 4.0) + storage.delete_variable(x) + self.assert_protos_equiv(_ModelUpdateProto(), tracker.export_update()) + + def test_objective_update_old_new_ordering( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + old_handles = [] + for i in range(4): + x = storage.add_variable(-1.0, 2.5, True, f"x_{i}") + storage.set_linear_objective_coefficient(x, i + 1.0) + old_handles.append(x) + tracker.advance_checkpoint() + for i in range(4): + x = storage.add_variable(-1.0, 2.5, True, f"x_{i+4}") + storage.set_linear_objective_coefficient(x, i + 10.0) + for i, h in enumerate(old_handles): + storage.set_linear_objective_coefficient(h, -2.0 * i) + self.assert_protos_equiv( + _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[4, 5, 6, 7], + lower_bounds=[-1.0, -1.0, -1.0, -1.0], + upper_bounds=[2.5, 2.5, 2.5, 2.5], + integers=[True, True, True, True], + names=["x_4", "x_5", "x_6", "x_7"], + ), + objective_updates=_ObjectiveUpdatesProto( + linear_coefficients=_SparseDoubleVectorProto( + ids=[0, 1, 2, 3, 4, 5, 6, 7], + values=[0.0, -2.0, -4.0, -6.0, 10.0, 11.0, 12.0, 13.0], + ) + ), + ), + tracker.export_update(), + ) + + def test_quadratic_objective_update_existing_zero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + tracker.advance_checkpoint() + storage.set_quadratic_objective_coefficient(x, y, 3.0) + expected = _ModelUpdateProto( + objective_updates=_ObjectiveUpdatesProto( + quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[0], column_ids=[1], coefficients=[3.0] + ) + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_quadratic_objective_update_existing_zero_same( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + tracker.advance_checkpoint() + storage.set_quadratic_objective_coefficient(x, y, 0.0) + self.assertIsNone(tracker.export_update()) + + def test_quadratic_objective_update_existing_nonzero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, y, 4.0) + tracker.advance_checkpoint() + storage.set_quadratic_objective_coefficient(x, y, 3.0) + expected = _ModelUpdateProto( + objective_updates=_ObjectiveUpdatesProto( + quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[0], column_ids=[1], coefficients=[3.0] + ) + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_quadratic_objective_update_existing_nonzero_same( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, y, 4.0) + tracker.advance_checkpoint() + storage.set_quadratic_objective_coefficient(x, y, 4.0) + self.assertIsNone(tracker.export_update()) + + def test_quadratic_objective_update_clear( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(0.0, 1.0, False, "y") + z = storage.add_variable(0.0, 1.0, True, "z") + + storage.set_linear_objective_coefficient(x, 2.0) + storage.set_linear_objective_coefficient(z, -5.5) + storage.set_quadratic_objective_coefficient(x, y, 4.0) + storage.set_objective_offset(1.0) + self.assertEqual(2.0, storage.get_linear_objective_coefficient(x)) + self.assertEqual(0.0, storage.get_linear_objective_coefficient(y)) + self.assertEqual(-5.5, storage.get_linear_objective_coefficient(z)) + self.assertEqual(0.0, storage.get_quadratic_objective_coefficient(x, x)) + self.assertEqual(4.0, storage.get_quadratic_objective_coefficient(x, y)) + self.assertEqual(1.0, storage.get_objective_offset()) + tracker.advance_checkpoint() + w = storage.add_variable(0.0, 1.0, True, "w") + storage.set_linear_objective_coefficient(w, 1.0) + storage.set_quadratic_objective_coefficient(w, w, 2.0) + storage.clear_objective() + expected = _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[3], + lower_bounds=[0.0], + upper_bounds=[1.0], + integers=[True], + names=["w"], + ), + objective_updates=_ObjectiveUpdatesProto( + offset_update=0.0, + linear_coefficients=_SparseDoubleVectorProto( + ids=[x, z], values=[0.0, 0.0] + ), + quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[x], column_ids=[y], coefficients=[0.0] + ), + ), + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_quadratic_objective_update_existing_to_zero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, y, 4.0) + tracker.advance_checkpoint() + storage.set_quadratic_objective_coefficient(x, y, 0.0) + expected = _ModelUpdateProto( + objective_updates=_ObjectiveUpdatesProto( + quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[x], column_ids=[y], coefficients=[0.0] + ) + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_quadratic_objective_update_existing_then_delete( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, y, 4.0) + tracker.advance_checkpoint() + storage.set_quadratic_objective_coefficient(x, y, 2.0) + storage.delete_variable(x) + self.assert_protos_equiv( + _ModelUpdateProto(deleted_variable_ids=[0]), tracker.export_update() + ) + + def test_quadratic_objective_update_new(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_quadratic_objective_coefficient(x, x, 4.0) + self.assert_protos_equiv( + _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[0], + lower_bounds=[-1.0], + upper_bounds=[2.5], + integers=[True], + names=["x"], + ), + objective_updates=_ObjectiveUpdatesProto( + quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[x], column_ids=[x], coefficients=[4.0] + ) + ), + ), + tracker.export_update(), + ) + + def test_quadratic_objective_update_new_old_deleted( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + old_var1 = storage.add_variable(-1.0, 2.5, True, "old1") + old_var2 = storage.add_variable(-1.0, 2.5, True, "old2") + deleted_var1 = storage.add_variable(-1.0, 2.5, True, "deleted1") + deleted_var2 = storage.add_variable(-1.0, 2.5, True, "deleted2") + tracker.advance_checkpoint() + new_var1 = storage.add_variable(0.0, 1.0, True, "new1") + new_var2 = storage.add_variable(0.0, 1.0, True, "new2") + storage.set_quadratic_objective_coefficient(old_var1, old_var1, 1.0) + storage.set_quadratic_objective_coefficient(old_var1, old_var2, 2.0) + storage.set_quadratic_objective_coefficient(old_var1, new_var1, 3.0) + storage.set_quadratic_objective_coefficient(new_var1, new_var1, 4.0) + storage.set_quadratic_objective_coefficient(new_var1, new_var2, 5.0) + storage.set_quadratic_objective_coefficient(deleted_var1, deleted_var1, 6.0) + storage.set_quadratic_objective_coefficient(deleted_var1, deleted_var2, 7.0) + storage.set_quadratic_objective_coefficient(deleted_var1, old_var1, 8.0) + storage.set_quadratic_objective_coefficient(deleted_var1, new_var1, 9.0) + storage.delete_variable(deleted_var1) + storage.delete_variable(deleted_var2) + self.assert_protos_equiv( + _ModelUpdateProto( + deleted_variable_ids=[deleted_var1, deleted_var2], + new_variables=_VariablesProto( + ids=[new_var1, new_var2], + lower_bounds=[0.0, 0.0], + upper_bounds=[1.0, 1.0], + integers=[True, True], + names=["new1", "new2"], + ), + objective_updates=_ObjectiveUpdatesProto( + quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[old_var1, old_var1, old_var1, new_var1, new_var1], + column_ids=[ + old_var1, + old_var2, + new_var1, + new_var1, + new_var2, + ], + coefficients=[1.0, 2.0, 3.0, 4.0, 5.0], + ) + ), + ), + tracker.export_update(), + ) + + def test_quadratic_objective_update_new_zero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, y, 4.0) + storage.set_quadratic_objective_coefficient(x, y, 0.0) + storage.set_linear_objective_coefficient(x, 0.0) + self.assert_protos_equiv( + _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[0, 1], + lower_bounds=[-1.0, -1.0], + upper_bounds=[2.5, 2.5], + integers=[True, True], + names=["x", "y"], + ) + ), + tracker.export_update(), + ) + + def test_quadratic_objective_update_new_then_delete( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + y = storage.add_variable(-1.0, 2.5, True, "y") + storage.set_quadratic_objective_coefficient(x, y, 4.0) + storage.delete_variable(x) + storage.delete_variable(y) + self.assert_protos_equiv(_ModelUpdateProto(), tracker.export_update()) + + def test_quadratic_objective_update_old_new_ordering( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + old_handles = [] + for i in range(4): + x = storage.add_variable(-1.0, 2.5, True, f"x_{i}") + old_handles.append(x) + for i in range(3): + storage.set_quadratic_objective_coefficient( + old_handles[i], old_handles[i + 1], i + 1 + ) + tracker.advance_checkpoint() + new_handles = [] + for i in range(4): + x = storage.add_variable(-1.0, 2.5, True, f"x_{i+4}") + new_handles.append(x) + for i in range(3): + storage.set_quadratic_objective_coefficient( + new_handles[i], new_handles[i + 1], i + 10 + ) + for i in range(3): + storage.set_quadratic_objective_coefficient( + old_handles[i], old_handles[i + 1], -2.0 * i + ) + self.assert_protos_equiv( + _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[4, 5, 6, 7], + lower_bounds=[-1.0, -1.0, -1.0, -1.0], + upper_bounds=[2.5, 2.5, 2.5, 2.5], + integers=[True, True, True, True], + names=["x_4", "x_5", "x_6", "x_7"], + ), + objective_updates=_ObjectiveUpdatesProto( + quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[0, 1, 2, 4, 5, 6], + column_ids=[1, 2, 3, 5, 6, 7], + coefficients=[0, -2.0, -4.0, 10, 11, 12], + ) + ), + ), + tracker.export_update(), + ) + + def test_update_lin_con_mat_existing_zero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-1.0, 2.5, "c") + tracker.advance_checkpoint() + storage.set_linear_constraint_coefficient(c, x, 3.0) + expected = _ModelUpdateProto( + linear_constraint_matrix_updates=_SparseDoubleMatrixProto( + row_ids=[0], column_ids=[0], coefficients=[3.0] + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_update_lin_con_mat_existing_zero_same( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-1.0, 2.5, "c") + tracker.advance_checkpoint() + storage.set_linear_constraint_coefficient(c, x, 0.0) + self.assertIsNone(tracker.export_update()) + + def test_lin_con_mat_update_existing_nonzero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_coefficient(c, x, 1.0) + tracker.advance_checkpoint() + storage.set_linear_constraint_coefficient(c, x, 3.0) + expected = _ModelUpdateProto( + linear_constraint_matrix_updates=_SparseDoubleMatrixProto( + row_ids=[0], column_ids=[0], coefficients=[3.0] + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_lin_con_mat_update_existing_nonzero_same( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_coefficient(c, x, 1.0) + tracker.advance_checkpoint() + storage.set_linear_constraint_coefficient(c, x, 1.0) + self.assertIsNone(tracker.export_update()) + + def test_lin_con_mat_update_existing_to_zero( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_coefficient(c, x, 1.0) + tracker.advance_checkpoint() + storage.set_linear_constraint_coefficient(c, x, 0.0) + expected = _ModelUpdateProto( + linear_constraint_matrix_updates=_SparseDoubleMatrixProto( + row_ids=[0], column_ids=[0], coefficients=[0.0] + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + + def test_lin_con_mat_update_existing_then_delete_var( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_coefficient(c, x, 1.0) + tracker.advance_checkpoint() + storage.set_linear_constraint_coefficient(c, x, 6.0) + storage.delete_variable(x) + self.assert_protos_equiv( + _ModelUpdateProto(deleted_variable_ids=[0]), tracker.export_update() + ) + + def test_lin_con_mat_update_existing_then_delete_lin_con( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_coefficient(c, x, 1.0) + tracker.advance_checkpoint() + storage.set_linear_constraint_coefficient(c, x, 6.0) + storage.delete_linear_constraint(c) + self.assert_protos_equiv( + _ModelUpdateProto(deleted_linear_constraint_ids=[0]), + tracker.export_update(), + ) + + def test_lin_con_mat_update_existing_then_delete_both( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_coefficient(c, x, 1.0) + tracker.advance_checkpoint() + storage.set_linear_constraint_coefficient(c, x, 6.0) + storage.delete_linear_constraint(c) + storage.delete_variable(x) + self.assert_protos_equiv( + _ModelUpdateProto( + deleted_variable_ids=[0], deleted_linear_constraint_ids=[0] + ), + tracker.export_update(), + ) + + def test_lin_con_mat_update_new_var(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + tracker.advance_checkpoint() + x = storage.add_variable(-1.0, 2.5, True, "x") + storage.set_linear_constraint_coefficient(c, x, 4.0) + self.assert_protos_equiv( + _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[0], + lower_bounds=[-1.0], + upper_bounds=[2.5], + integers=[True], + names=["x"], + ), + linear_constraint_matrix_updates=_SparseDoubleMatrixProto( + row_ids=[0], column_ids=[0], coefficients=[4.0] + ), + ), + tracker.export_update(), + ) + + def test_lin_con_mat_update_new_lin_con(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_coefficient(c, x, 4.0) + self.assert_protos_equiv( + _ModelUpdateProto( + new_linear_constraints=_LinearConstraintsProto( + ids=[0], lower_bounds=[-1.0], upper_bounds=[2.5], names=["c"] + ), + linear_constraint_matrix_updates=_SparseDoubleMatrixProto( + row_ids=[0], column_ids=[0], coefficients=[4.0] + ), + ), + tracker.export_update(), + ) + + def test_lin_con_mat_update_new_both(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_coefficient(c, x, 4.0) + self.assert_protos_equiv( + _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[0], + lower_bounds=[-1.0], + upper_bounds=[2.5], + integers=[True], + names=["x"], + ), + new_linear_constraints=_LinearConstraintsProto( + ids=[0], lower_bounds=[-1.0], upper_bounds=[2.5], names=["c"] + ), + linear_constraint_matrix_updates=_SparseDoubleMatrixProto( + row_ids=[0], column_ids=[0], coefficients=[4.0] + ), + ), + tracker.export_update(), + ) + + def test_lin_con_mat_update_new_zero(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_coefficient(c, x, 4.0) + storage.set_linear_constraint_coefficient(c, x, 0.0) + self.assert_protos_equiv( + _ModelUpdateProto( + new_linear_constraints=_LinearConstraintsProto( + ids=[0], lower_bounds=[-1.0], upper_bounds=[2.5], names=["c"] + ) + ), + tracker.export_update(), + ) + + def test_lin_con_mat_update_new_then_delete( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + x = storage.add_variable(-1.0, 2.5, True, "x") + tracker.advance_checkpoint() + c = storage.add_linear_constraint(-1.0, 2.5, "c") + storage.set_linear_constraint_coefficient(c, x, 4.0) + storage.delete_variable(x) + self.assert_protos_equiv( + _ModelUpdateProto( + deleted_variable_ids=[0], + new_linear_constraints=_LinearConstraintsProto( + ids=[0], lower_bounds=[-1.0], upper_bounds=[2.5], names=["c"] + ), + ), + tracker.export_update(), + ) + + def test_lin_con_mat_update_old_new_ordering( + self, storage_class: _StorageClass + ) -> None: + storage = storage_class("test_model") + tracker = storage.add_update_tracker() + var_handles = [storage.add_variable(0.0, 1.0, True, "") for _ in range(2)] + lin_con_handles = [ + storage.add_linear_constraint(0.0, 1.0, "") for _ in range(2) + ] + for v in var_handles: + for l in lin_con_handles: + storage.set_linear_constraint_coefficient(l, v, 1.0) + tracker.advance_checkpoint() + x = storage.add_variable(0.0, 1.0, True, "x") + c = storage.add_linear_constraint(0.0, 1.0, "c") + storage.set_linear_constraint_coefficient( + lin_con_handles[0], var_handles[0], 5.0 + ) + storage.set_linear_constraint_coefficient(lin_con_handles[0], x, 4.0) + storage.set_linear_constraint_coefficient(c, var_handles[1], 3.0) + storage.set_linear_constraint_coefficient(c, x, 2.0) + self.assert_protos_equiv( + _ModelUpdateProto( + new_variables=_VariablesProto( + ids=[2], + lower_bounds=[0.0], + upper_bounds=[1.0], + integers=[True], + names=["x"], + ), + new_linear_constraints=_LinearConstraintsProto( + ids=[2], lower_bounds=[0.0], upper_bounds=[1.0], names=["c"] + ), + linear_constraint_matrix_updates=_SparseDoubleMatrixProto( + row_ids=[0, 0, 2, 2], + column_ids=[0, 2, 1, 2], + coefficients=[5.0, 4.0, 3.0, 2.0], + ), + ), + tracker.export_update(), + ) + + def test_remove_update_tracker(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + x = storage.add_variable(0.0, 1.0, True, "x") + tracker = storage.add_update_tracker() + storage.set_variable_ub(x, 7.0) + expected = _ModelUpdateProto( + variable_updates=_VariableUpdatesProto( + upper_bounds=_SparseDoubleVectorProto(ids=[0], values=[7.0]) + ) + ) + self.assert_protos_equiv(expected, tracker.export_update()) + storage.remove_update_tracker(tracker) + with self.assertRaises(model_storage.UsedUpdateTrackerAfterRemovalError): + tracker.export_update() + with self.assertRaises(model_storage.UsedUpdateTrackerAfterRemovalError): + tracker.advance_checkpoint() + with self.assertRaises(KeyError): + storage.remove_update_tracker(tracker) + + def test_remove_update_tracker_wrong_model( + self, storage_class: _StorageClass + ) -> None: + storage1 = storage_class("test_model1") + storage2 = storage_class("test_model2") + tracker1 = storage1.add_update_tracker() + with self.assertRaises(KeyError): + storage2.remove_update_tracker(tracker1) + + def test_multiple_update_tracker(self, storage_class: _StorageClass) -> None: + storage = storage_class("test_model") + x = storage.add_variable(0.0, 1.0, True, "x") + y = storage.add_variable(0.0, 1.0, True, "y") + tracker1 = storage.add_update_tracker() + storage.set_variable_ub(x, 7.0) + tracker2 = storage.add_update_tracker() + storage.set_variable_ub(y, 3.0) + self.assert_protos_equiv( + _ModelUpdateProto( + variable_updates=_VariableUpdatesProto( + upper_bounds=_SparseDoubleVectorProto(ids=[0, 1], values=[7.0, 3.0]) + ) + ), + tracker1.export_update(), + ) + self.assert_protos_equiv( + _ModelUpdateProto( + variable_updates=_VariableUpdatesProto( + upper_bounds=_SparseDoubleVectorProto(ids=[1], values=[3.0]) + ) + ), + tracker2.export_update(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/model_test.py b/ortools/math_opt/python/model_test.py new file mode 100644 index 0000000000..7671b980b9 --- /dev/null +++ b/ortools/math_opt/python/model_test.py @@ -0,0 +1,1110 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math +from typing import Type + +import unittest +from google3.testing.pybase import parameterized +from ortools.math_opt import model_pb2 +from ortools.math_opt import model_update_pb2 +from ortools.math_opt import sparse_containers_pb2 +from ortools.math_opt.python import hash_model_storage +from ortools.math_opt.python import model +from ortools.math_opt.python import model_storage +from ortools.math_opt.python.testing import compare_proto + +StorageClass = Type[model_storage.ModelStorage] +_MatEntry = model_storage.LinearConstraintMatrixIdEntry +_ObjEntry = model_storage.LinearObjectiveEntry + + +@parameterized.parameters((hash_model_storage.HashModelStorage,)) +class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase): + def test_name(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + self.assertEqual("test_model", mod.name) + + def test_name_empty(self, storage_class: StorageClass) -> None: + mod = model.Model(storage_class=storage_class) + self.assertEqual("", mod.name) + + def test_add_and_read_variables(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + v1 = mod.add_variable(lb=-1.0, ub=2.5, is_integer=True, name="x") + v2 = mod.add_variable() + self.assertEqual(-1.0, v1.lower_bound) + self.assertEqual(2.5, v1.upper_bound) + self.assertTrue(v1.integer) + self.assertEqual("x", v1.name) + self.assertEqual(0, v1.id) + self.assertEqual("x", str(v1)) + self.assertEqual("", repr(v1)) + + self.assertEqual(-math.inf, v2.lower_bound) + self.assertEqual(math.inf, v2.upper_bound) + self.assertFalse(v2.integer) + self.assertEqual("", v2.name) + self.assertEqual(1, v2.id) + self.assertEqual("variable_1", str(v2)) + self.assertEqual("", repr(v2)) + + self.assertListEqual([v1, v2], list(mod.variables())) + self.assertEqual(v1, mod.get_variable(0)) + self.assertEqual(v2, mod.get_variable(1)) + + def test_get_variable_does_not_exist_key_error( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + with self.assertRaisesRegex(KeyError, "does not exist.*3"): + mod.get_variable(3) + + def test_add_integer_variable(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + v1 = mod.add_integer_variable(lb=-1.0, ub=2.5, name="x") + self.assertEqual(-1.0, v1.lower_bound) + self.assertEqual(2.5, v1.upper_bound) + self.assertTrue(v1.integer) + self.assertEqual("x", v1.name) + self.assertEqual(0, v1.id) + + def test_add_binary_variable(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + v1 = mod.add_binary_variable(name="x") + self.assertEqual(0.0, v1.lower_bound) + self.assertEqual(1.0, v1.upper_bound) + self.assertTrue(v1.integer) + self.assertEqual("x", v1.name) + self.assertEqual(0, v1.id) + + def test_update_variable(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + v1 = mod.add_variable(lb=-1.0, ub=2.5, is_integer=True, name="x") + v1.lower_bound = -math.inf + v1.upper_bound = -3.0 + v1.integer = False + self.assertEqual(-math.inf, v1.lower_bound) + self.assertEqual(-3.0, v1.upper_bound) + self.assertFalse(v1.integer) + + def test_delete_variable(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + self.assertListEqual([x, y, z], list(mod.variables())) + mod.delete_variable(y) + self.assertListEqual([x, z], list(mod.variables())) + + def test_delete_variable_twice(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + mod.delete_variable(x) + with self.assertRaises(LookupError): + mod.delete_variable(x) + + def test_read_deleted_variable(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + mod.delete_variable(x) + with self.assertRaises(LookupError): + x.lower_bound # pylint: disable=pointless-statement + + def test_update_deleted_variable(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + mod.delete_variable(x) + with self.assertRaises(LookupError): + x.upper_bound = 2.0 + + def test_delete_variable_wrong_model(self, storage_class: StorageClass) -> None: + mod1 = model.Model(name="mod1", storage_class=storage_class) + mod1.add_binary_variable(name="x") + mod2 = model.Model(name="mod2", storage_class=storage_class) + x2 = mod2.add_binary_variable(name="x") + with self.assertRaises(ValueError): + mod1.delete_variable(x2) + + def test_add_and_read_linear_constraints(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c") + d = mod.add_linear_constraint() + self.assertEqual(-1.0, c.lower_bound) + self.assertEqual(2.5, c.upper_bound) + self.assertEqual("c", c.name) + self.assertEqual(0, c.id) + self.assertEqual("c", str(c)) + self.assertEqual("", repr(c)) + + self.assertEqual(-math.inf, d.lower_bound) + self.assertEqual(math.inf, d.upper_bound) + self.assertEqual("", d.name) + self.assertEqual(1, d.id) + self.assertEqual("linear_constraint_1", str(d)) + self.assertEqual("", repr(d)) + + self.assertListEqual([c, d], list(mod.linear_constraints())) + self.assertEqual(c, mod.get_linear_constraint(0)) + self.assertEqual(d, mod.get_linear_constraint(1)) + + def test_get_linear_constraint_does_not_exist_key_error( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + with self.assertRaisesRegex(KeyError, "does not exist.*3"): + mod.get_linear_constraint(3) + + def test_update_linear_constraint(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c") + c.lower_bound = -math.inf + c.upper_bound = -3.0 + self.assertEqual(-math.inf, c.lower_bound) + self.assertEqual(-3.0, c.upper_bound) + + def test_delete_linear_constraint(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + e = mod.add_linear_constraint(lb=1.0, name="e") + self.assertListEqual([c, d, e], list(mod.linear_constraints())) + mod.delete_linear_constraint(d) + self.assertListEqual([c, e], list(mod.linear_constraints())) + + def test_delete_linear_constraint_twice(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c") + mod.delete_linear_constraint(c) + with self.assertRaises(LookupError): + mod.delete_linear_constraint(c) + + def test_read_deleted_linear_constraint(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c") + mod.delete_linear_constraint(c) + with self.assertRaises(LookupError): + c.name # pylint: disable=pointless-statement + + def test_update_deleted_linear_constraint( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c") + mod.delete_linear_constraint(c) + with self.assertRaises(LookupError): + c.lower_bound = -12.0 + + def test_delete_linear_constraint_wrong_model( + self, storage_class: StorageClass + ) -> None: + mod1 = model.Model(name="test_model", storage_class=storage_class) + mod1.add_linear_constraint(lb=-1.0, ub=2.5, name="c") + mod2 = model.Model(name="mod2", storage_class=storage_class) + c2 = mod2.add_linear_constraint(lb=-1.0, ub=2.5, name="c") + with self.assertRaises(ValueError): + mod1.delete_linear_constraint(c2) + + def test_set_objective_linear(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + w = mod.add_binary_variable(name="w") + + mod.set_objective(2 * (x - 2 * y) + 1 + 3 * z, is_maximize=True) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(3.0, mod.objective.get_linear_coefficient(z)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(w)) + self.assertEqual(1.0, mod.objective.offset) + self.assertTrue(mod.objective.is_maximize) + + mod.set_objective(w + 2, is_maximize=False) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(z)) + self.assertEqual(1.0, mod.objective.get_linear_coefficient(w)) + self.assertEqual(2.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + def test_set_linear_objective(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + w = mod.add_binary_variable(name="w") + + mod.set_linear_objective(2 * (x - 2 * y) + 1 + 3 * z, is_maximize=True) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(3.0, mod.objective.get_linear_coefficient(z)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(w)) + self.assertEqual(1.0, mod.objective.offset) + self.assertTrue(mod.objective.is_maximize) + + mod.set_linear_objective(w + 2, is_maximize=False) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(z)) + self.assertEqual(1.0, mod.objective.get_linear_coefficient(w)) + self.assertEqual(2.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + def test_set_objective_quadratic(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + + mod.set_objective(2 * x * (x - 2 * y) + 1 + 3 * x, is_maximize=True) + self.assertEqual(3.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(2.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(-4.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(1.0, mod.objective.offset) + self.assertTrue(mod.objective.is_maximize) + + mod.set_objective(x * x + 2, is_maximize=False) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(2.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + def test_set_quadratic_objective(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + + mod.set_quadratic_objective(2 * x * (x - 2 * y) + 1 + 3 * x, is_maximize=True) + self.assertEqual(3.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(2.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(-4.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(1.0, mod.objective.offset) + self.assertTrue(mod.objective.is_maximize) + + mod.set_quadratic_objective(x * x + 2, is_maximize=False) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(2.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + def test_maximize_expr_linear(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.maximize(2 * x - y + 1) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(1.0, mod.objective.offset) + self.assertTrue(mod.objective.is_maximize) + + mod.objective.clear() + mod.maximize_linear_objective(2 * x - y + 1) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(1.0, mod.objective.offset) + self.assertTrue(mod.objective.is_maximize) + + def test_maximize_expr_quadratic(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.maximize(2 * x - y + 1 + x * x) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(1.0, mod.objective.offset) + self.assertTrue(mod.objective.is_maximize) + + mod.objective.clear() + mod.maximize_quadratic_objective(2 * x - y + 1 + x * x) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(1.0, mod.objective.offset) + self.assertTrue(mod.objective.is_maximize) + + def test_minimize_expr_linear(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.minimize(2 * x - y + 1) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(1.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + mod.objective.clear() + mod.minimize_linear_objective(2 * x - y + 1) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(1.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + def test_minimize_expr_quadratic(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.minimize(2 * x - y + 1 + x * x) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(1.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + mod.objective.clear() + mod.minimize_quadratic_objective(2 * x - y + 1 + x * x) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(1.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + def test_add_to_objective_linear(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.minimize(2 * x - y + 1) + mod.objective.add(x - 3 * y - 2) + self.assertEqual(3.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(-1.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + mod.minimize(2 * x - y + 1) + mod.objective.add_linear(x - 3 * y - 2) + self.assertEqual(3.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(-1.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + def test_add_to_objective_quadratic(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.minimize(2 * x - y + 1 + x * x) + mod.objective.add(x - 3 * y - 2 - 2 * x * x + x * y) + self.assertEqual(3.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(-1.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(-1.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + mod.minimize(2 * x - y + 1 + x * x) + mod.objective.add_quadratic(x - 3 * y - 2 - 2 * x * x + x * y) + self.assertEqual(3.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(-1.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(-1.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + def test_add_to_objective_type_errors(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + with self.assertRaises(TypeError): + mod.objective.add_linear(x * x) # pytype: disable=wrong-arg-types + with self.assertRaises(TypeError): + mod.objective.add("obj") # pytype: disable=wrong-arg-types + with self.assertRaises(TypeError): + mod.objective.add_quadratic("obj") # pytype: disable=wrong-arg-types + + def test_clear_objective(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.minimize(2 * x - y + 1 + x * x) + mod.objective.clear() + self.assertEqual(0.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y)) + self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y)) + self.assertEqual(0.0, mod.objective.offset) + self.assertFalse(mod.objective.is_maximize) + + def test_objective_offset(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + self.assertEqual(0.0, mod.objective.offset) + mod.objective.offset = 4.4 + self.assertEqual(4.4, mod.objective.offset) + + def test_objective_direction(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + self.assertFalse(mod.objective.is_maximize) + mod.objective.is_maximize = True + self.assertTrue(mod.objective.is_maximize) + mod.objective.is_maximize = False + self.assertFalse(mod.objective.is_maximize) + + def test_objective_linear_terms(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + w = mod.add_binary_variable(name="w") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + for v in (w, x, y, z): + self.assertEqual(0.0, mod.objective.get_linear_coefficient(v)) + self.assertCountEqual([], mod.objective.linear_terms()) + mod.objective.set_linear_coefficient(x, 0.0) + mod.objective.set_linear_coefficient(y, 1.0) + mod.objective.set_linear_coefficient(z, 10.0) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(w)) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(x)) + self.assertEqual(1.0, mod.objective.get_linear_coefficient(y)) + self.assertEqual(10.0, mod.objective.get_linear_coefficient(z)) + self.assertCountEqual( + [ + repr(model.LinearTerm(variable=y, coefficient=1.0)), + repr(model.LinearTerm(variable=z, coefficient=10.0)), + ], + [repr(term) for term in mod.objective.linear_terms()], + ) + + mod.objective.set_linear_coefficient(z, 0.0) + self.assertEqual(0.0, mod.objective.get_linear_coefficient(z)) + self.assertCountEqual( + [repr(model.LinearTerm(variable=y, coefficient=1.0))], + [repr(term) for term in mod.objective.linear_terms()], + ) + + def test_objective_quadratic_terms(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + self.assertCountEqual([], mod.objective.quadratic_terms()) + mod.objective.set_linear_coefficient(x, 0.0) + mod.objective.set_quadratic_coefficient(x, x, 1.0) + mod.objective.set_quadratic_coefficient(x, y, 2.0) + self.assertCountEqual( + [ + repr( + model.QuadraticTerm( + key=model.QuadraticTermKey(x, x), coefficient=1.0 + ) + ), + repr( + model.QuadraticTerm( + key=model.QuadraticTermKey(x, y), coefficient=2.0 + ) + ), + ], + [repr(term) for term in mod.objective.quadratic_terms()], + ) + + mod.objective.set_quadratic_coefficient(x, x, 0.0) + self.assertCountEqual( + [ + repr( + model.QuadraticTerm( + key=model.QuadraticTermKey(x, y), coefficient=2.0 + ) + ) + ], + [repr(term) for term in mod.objective.quadratic_terms()], + ) + + mod.objective.set_quadratic_coefficient(x, y, 0.0) + self.assertEmpty(list(mod.objective.quadratic_terms())) + + def test_objective_as_expression_linear(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.maximize(x + 2 * y - 1) + linear_expr = mod.objective.as_linear_expression() + quadratic_expr = mod.objective.as_quadratic_expression() + self.assertEqual(-1, linear_expr.offset) + self.assertEqual(-1, quadratic_expr.offset) + self.assertDictEqual(dict(linear_expr.terms), {x: 1.0, y: 2.0}) + self.assertDictEqual(dict(quadratic_expr.linear_terms), {x: 1.0, y: 2.0}) + self.assertDictEqual(dict(quadratic_expr.quadratic_terms), {}) + + def test_objective_as_expression_quadratic( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.maximize(3 * x * y + 4 * x * x + x + 2 * y - 1) + quadratic_expr = mod.objective.as_quadratic_expression() + self.assertEqual(-1, quadratic_expr.offset) + self.assertDictEqual(dict(quadratic_expr.linear_terms), {x: 1.0, y: 2.0}) + self.assertDictEqual( + dict(quadratic_expr.quadratic_terms), + {model.QuadraticTermKey(x, x): 4, model.QuadraticTermKey(x, y): 3}, + ) + with self.assertRaises(TypeError): + mod.objective.as_linear_expression() + + def test_objective_with_variable_deletion_linear( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.objective.set_linear_coefficient(x, 1.0) + mod.objective.set_linear_coefficient(y, 2.0) + mod.delete_variable(x) + self.assertEqual(2.0, mod.objective.get_linear_coefficient(y)) + self.assertCountEqual( + [repr(model.LinearTerm(variable=y, coefficient=2.0))], + [repr(term) for term in mod.objective.linear_terms()], + ) + with self.assertRaises(LookupError): + mod.objective.get_linear_coefficient(x) + + def test_objective_with_variable_deletion_quadratic( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.objective.set_quadratic_coefficient(x, x, 1.0) + mod.objective.set_quadratic_coefficient(x, y, 2.0) + mod.delete_variable(y) + self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x)) + self.assertCountEqual( + [ + repr( + model.QuadraticTerm( + key=model.QuadraticTermKey(x, x), coefficient=1.0 + ) + ) + ], + [repr(term) for term in mod.objective.quadratic_terms()], + ) + with self.assertRaises(LookupError): + mod.objective.get_quadratic_coefficient(x, y) + with self.assertRaises(LookupError): + mod.objective.get_quadratic_coefficient(y, x) + + def test_objective_wrong_model_linear(self, storage_class: StorageClass) -> None: + mod1 = model.Model(name="test_model1", storage_class=storage_class) + x = mod1.add_binary_variable(name="x") + mod2 = model.Model(name="test_model2", storage_class=storage_class) + mod2.add_binary_variable(name="x") + with self.assertRaises(ValueError): + mod2.objective.set_linear_coefficient(x, 1.0) + + def test_objective_wrong_model_quadratic(self, storage_class: StorageClass) -> None: + mod1 = model.Model(name="test_model1", storage_class=storage_class) + x = mod1.add_binary_variable(name="x") + mod2 = model.Model(name="test_model2", storage_class=storage_class) + other_x = mod2.add_binary_variable(name="x") + with self.assertRaises(ValueError): + mod2.objective.set_quadratic_coefficient(x, other_x, 1.0) + with self.assertRaises(ValueError): + mod2.objective.set_quadratic_coefficient(other_x, x, 1.0) + + def test_objective_type_errors(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + with self.assertRaises(TypeError): + mod.set_linear_objective( + x * x, is_maximize=True + ) # pytype: disable=wrong-arg-types + with self.assertRaises(TypeError): + mod.maximize_linear_objective(x * x) # pytype: disable=wrong-arg-types + with self.assertRaises(TypeError): + mod.minimize_linear_objective(x * x) # pytype: disable=wrong-arg-types + with self.assertRaises(TypeError): + mod.set_quadratic_objective( + "obj", is_maximize=True + ) # pytype: disable=wrong-arg-types + with self.assertRaises(TypeError): + mod.maximize_quadratic_objective("obj") # pytype: disable=wrong-arg-types + with self.assertRaises(TypeError): + mod.minimize_quadratic_objective("obj") # pytype: disable=wrong-arg-types + with self.assertRaises(TypeError): + mod.set_objective( + "obj", is_maximize=True + ) # pytype: disable=wrong-arg-types + with self.assertRaises(TypeError): + mod.minimize("obj") # pytype: disable=wrong-arg-types + with self.assertRaises(TypeError): + mod.maximize("obj") # pytype: disable=wrong-arg-types + + def test_linear_constraint_matrix(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(ub=1.0, name="d") + c.set_coefficient(x, 1.0) + c.set_coefficient(y, 0.0) + d.set_coefficient(x, 2.0) + d.set_coefficient(z, -1.0) + self.assertEqual(1.0, c.get_coefficient(x)) + self.assertEqual(0.0, c.get_coefficient(y)) + self.assertEqual(0.0, c.get_coefficient(z)) + self.assertEqual(2.0, d.get_coefficient(x)) + self.assertEqual(0.0, d.get_coefficient(y)) + self.assertEqual(-1.0, d.get_coefficient(z)) + + self.assertEqual(c.name, "c") + self.assertEqual(d.name, "d") + + self.assertCountEqual([c, d], mod.column_nonzeros(x)) + self.assertCountEqual([], mod.column_nonzeros(y)) + self.assertCountEqual([d], mod.column_nonzeros(z)) + + self.assertCountEqual( + [repr(model.LinearTerm(variable=x, coefficient=1.0))], + [repr(term) for term in c.terms()], + ) + self.assertCountEqual( + [ + repr(model.LinearTerm(variable=x, coefficient=2.0)), + repr(model.LinearTerm(variable=z, coefficient=-1.0)), + ], + [repr(term) for term in d.terms()], + ) + + self.assertCountEqual( + [ + model.LinearConstraintMatrixEntry( + linear_constraint=c, variable=x, coefficient=1.0 + ), + model.LinearConstraintMatrixEntry( + linear_constraint=d, variable=x, coefficient=2.0 + ), + model.LinearConstraintMatrixEntry( + linear_constraint=d, variable=z, coefficient=-1.0 + ), + ], + mod.linear_constraint_matrix_entries(), + ) + + def test_linear_constraint_expression(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + c = mod.add_linear_constraint(lb=0.0, expr=x + 1.0, ub=1.0, name="c") + self.assertEqual(1.0, c.get_coefficient(x)) + self.assertEqual(0.0, c.get_coefficient(y)) + self.assertEqual(0.0, c.get_coefficient(z)) + self.assertEqual(-1.0, c.lower_bound) + self.assertEqual(0.0, c.upper_bound) + + d = mod.add_linear_constraint(ub=1.0, expr=2 * x - z, name="d") + self.assertEqual(2.0, d.get_coefficient(x)) + self.assertEqual(0.0, d.get_coefficient(y)) + self.assertEqual(-1.0, d.get_coefficient(z)) + self.assertEqual(-math.inf, d.lower_bound) + self.assertEqual(1.0, d.upper_bound) + + e = mod.add_linear_constraint(lb=0.0) + self.assertEqual(0.0, e.get_coefficient(x)) + self.assertEqual(0.0, e.get_coefficient(y)) + self.assertEqual(0.0, e.get_coefficient(z)) + self.assertEqual(0.0, e.lower_bound) + self.assertEqual(math.inf, e.upper_bound) + + f = mod.add_linear_constraint(expr=1, ub=2) + self.assertEqual(0.0, f.get_coefficient(x)) + self.assertEqual(0.0, f.get_coefficient(y)) + self.assertEqual(0.0, f.get_coefficient(z)) + self.assertEqual(-math.inf, f.lower_bound) + self.assertEqual(1, f.upper_bound) + + def test_linear_constraint_bounded_expression( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + c = mod.add_linear_constraint((0.0 <= x + 1.0) <= 1.0, name="c") + self.assertEqual(1.0, c.get_coefficient(x)) + self.assertEqual(0.0, c.get_coefficient(y)) + self.assertEqual(0.0, c.get_coefficient(z)) + self.assertEqual(-1.0, c.lower_bound) + self.assertEqual(0.0, c.upper_bound) + + def test_linear_constraint_upper_bounded_expression( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + d = mod.add_linear_constraint(2 * x - z + 2.0 <= 1.0, name="d") + self.assertEqual(2.0, d.get_coefficient(x)) + self.assertEqual(0.0, d.get_coefficient(y)) + self.assertEqual(-1.0, d.get_coefficient(z)) + self.assertEqual(-math.inf, d.lower_bound) + self.assertEqual(-1.0, d.upper_bound) + + def test_linear_constraint_lower_bounded_expression( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + e = mod.add_linear_constraint(1.0 <= x + y + 2.0, name="e") + self.assertEqual(1.0, e.get_coefficient(x)) + self.assertEqual(1.0, e.get_coefficient(y)) + self.assertEqual(0.0, e.get_coefficient(z)) + self.assertEqual(-1.0, e.lower_bound) + self.assertEqual(math.inf, e.upper_bound) + + def test_linear_constraint_number_eq_expression( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + f = mod.add_linear_constraint(1.0 == x + y + 2.0, name="e") + self.assertEqual(1.0, f.get_coefficient(x)) + self.assertEqual(1.0, f.get_coefficient(y)) + self.assertEqual(0.0, f.get_coefficient(z)) + self.assertEqual(-1.0, f.lower_bound) + self.assertEqual(-1.0, f.upper_bound) + + def test_linear_constraint_expression_eq_expression( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + f = mod.add_linear_constraint(1.0 - x == y + 2.0, name="e") + self.assertEqual(-1.0, f.get_coefficient(x)) + self.assertEqual(-1.0, f.get_coefficient(y)) + self.assertEqual(0.0, f.get_coefficient(z)) + self.assertEqual(1.0, f.lower_bound) + self.assertEqual(1.0, f.upper_bound) + + def test_linear_constraint_variable_eq_variable( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + f = mod.add_linear_constraint(x == y, name="e") + self.assertEqual(1.0, f.get_coefficient(x)) + self.assertEqual(-1.0, f.get_coefficient(y)) + self.assertEqual(0.0, f.get_coefficient(z)) + self.assertEqual(0.0, f.lower_bound) + self.assertEqual(0.0, f.upper_bound) + + def test_linear_constraint_errors(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + + with self.assertRaisesRegex( + TypeError, + "unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left", + ): + mod.add_linear_constraint(x != y) + + with self.assertRaisesRegex(TypeError, "!= constraints.*"): + mod.add_linear_constraint(x + y != y) + + with self.assertRaisesRegex(TypeError, "!= constraints.*"): + mod.add_linear_constraint(x != x + y) + + with self.assertRaisesRegex( + TypeError, + "unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left", + ): + mod.add_linear_constraint(1 <= 2) # pylint: disable=comparison-of-constants + + with self.assertRaisesRegex( + TypeError, + "unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left", + ): + mod.add_linear_constraint(1 <= 0) # pylint: disable=comparison-of-constants + + with self.assertRaisesRegex( + TypeError, + "unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left", + ): + mod.add_linear_constraint(True) + + with self.assertRaisesRegex( + TypeError, + "__bool__ is unsupported.*\n.*two-sided or ranged linear inequality", + ): + mod.add_linear_constraint(x <= y <= z) + + with self.assertRaisesRegex( + TypeError, + "unsupported operand.*\n.*two or more non-constant linear expressions", + ): + mod.add_linear_constraint((x <= y) <= z) + + with self.assertRaisesRegex( + TypeError, + "unsupported operand.*\n.*two or more non-constant linear expressions", + ): + mod.add_linear_constraint(x <= (y <= z)) + + with self.assertRaisesRegex(TypeError, "unsupported operand.*"): + mod.add_linear_constraint((0 <= x) >= z) + + with self.assertRaisesRegex(ValueError, "lb cannot be specified.*"): + mod.add_linear_constraint(x + y == 1, lb=1) + + with self.assertRaisesRegex(ValueError, "ub cannot be specified.*"): + mod.add_linear_constraint(x + y == 1, ub=1) + + with self.assertRaisesRegex(ValueError, "expr cannot be specified.*"): + mod.add_linear_constraint(x + y == 1, expr=2 * x) + + with self.assertRaisesRegex( + TypeError, "unsupported type for expr argument.*str" + ): + mod.add_linear_constraint(expr="string") # pytype: disable=wrong-arg-types + + with self.assertRaisesRegex(ValueError, ".*infinite offset."): + mod.add_linear_constraint(expr=math.inf, lb=0.0) + + def test_linear_constraint_matrix_with_variable_deletion( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + c.set_coefficient(x, 1.0) + c.set_coefficient(y, 2.0) + d.set_coefficient(x, 1.0) + mod.delete_variable(x) + self.assertCountEqual( + [ + model.LinearConstraintMatrixEntry( + linear_constraint=c, variable=y, coefficient=2.0 + ) + ], + mod.linear_constraint_matrix_entries(), + ) + self.assertCountEqual([c], mod.column_nonzeros(y)) + self.assertCountEqual( + [repr(model.LinearTerm(variable=y, coefficient=2.0))], + [repr(term) for term in c.terms()], + ) + self.assertCountEqual([], d.terms()) + with self.assertRaises(LookupError): + c.get_coefficient(x) + + def test_linear_constraint_matrix_with_linear_constraint_deletion( + self, storage_class: StorageClass + ) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + c.set_coefficient(x, 1.0) + c.set_coefficient(y, 2.0) + d.set_coefficient(x, 1.0) + mod.delete_linear_constraint(c) + self.assertCountEqual( + [ + model.LinearConstraintMatrixEntry( + linear_constraint=d, variable=x, coefficient=1.0 + ) + ], + mod.linear_constraint_matrix_entries(), + ) + self.assertCountEqual([d], mod.column_nonzeros(x)) + self.assertCountEqual([], mod.column_nonzeros(y)) + self.assertCountEqual( + [repr(model.LinearTerm(variable=x, coefficient=1.0))], + [repr(term) for term in d.terms()], + ) + + def test_linear_constraint_matrix_wrong_model( + self, storage_class: StorageClass + ) -> None: + mod1 = model.Model(name="test_model1", storage_class=storage_class) + x1 = mod1.add_binary_variable(name="x") + mod2 = model.Model(name="test_model2", storage_class=storage_class) + mod2.add_binary_variable(name="x") + c2 = mod2.add_linear_constraint(lb=0.0, ub=1.0, name="c") + with self.assertRaises(ValueError): + c2.set_coefficient(x1, 1.0) + + def test_export(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + mod.objective.offset = 2.0 + mod.objective.is_maximize = True + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=2.0, name="c") + c.set_coefficient(x, 1.0) + c.set_coefficient(y, 2.0) + mod.objective.set_linear_coefficient(y, 3.0) + expected = model_pb2.ModelProto( + name="test_model", + variables=model_pb2.VariablesProto( + ids=[0, 1], + lower_bounds=[0.0, 0.0], + upper_bounds=[1.0, 1.0], + integers=[True, True], + names=["x", "y"], + ), + linear_constraints=model_pb2.LinearConstraintsProto( + ids=[0], lower_bounds=[0.0], upper_bounds=[2.0], names=["c"] + ), + objective=model_pb2.ObjectiveProto( + maximize=True, + offset=2.0, + linear_coefficients=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[1], values=[3.0] + ), + ), + linear_constraint_matrix=sparse_containers_pb2.SparseDoubleMatrixProto( + row_ids=[0, 0], column_ids=[0, 1], coefficients=[1.0, 2.0] + ), + ) + self.assert_protos_equiv(expected, mod.export_model()) + + def test_update_tracker_simple(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + t = mod.add_update_tracker() + x.upper_bound = 2.0 + expected = model_update_pb2.ModelUpdateProto( + variable_updates=model_update_pb2.VariableUpdatesProto( + upper_bounds=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[2.0] + ) + ) + ) + self.assert_protos_equiv(expected, t.export_update()) + self.assert_protos_equiv(expected, t.export_update()) + t.advance_checkpoint() + self.assertIsNone(t.export_update()) + + def test_two_update_trackers(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + t1 = mod.add_update_tracker() + x = mod.add_binary_variable(name="x") + t2 = mod.add_update_tracker() + x.upper_bound = 2.0 + expected1 = model_update_pb2.ModelUpdateProto( + new_variables=model_pb2.VariablesProto( + ids=[0], + lower_bounds=[0.0], + upper_bounds=[2.0], + integers=[True], + names=["x"], + ) + ) + expected2 = model_update_pb2.ModelUpdateProto( + variable_updates=model_update_pb2.VariableUpdatesProto( + upper_bounds=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[2.0] + ) + ) + ) + self.assert_protos_equiv(expected1, t1.export_update()) + self.assert_protos_equiv(expected2, t2.export_update()) + + def test_remove_tracker(self, storage_class: StorageClass) -> None: + mod = model.Model(name="test_model", storage_class=storage_class) + x = mod.add_binary_variable(name="x") + t1 = mod.add_update_tracker() + t2 = mod.add_update_tracker() + x.upper_bound = 2.0 + mod.remove_update_tracker(t1) + x.lower_bound = -1.0 + expected = model_update_pb2.ModelUpdateProto( + variable_updates=model_update_pb2.VariableUpdatesProto( + upper_bounds=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[2.0] + ), + lower_bounds=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[-1.0] + ), + ) + ) + self.assert_protos_equiv(expected, t2.export_update()) + with self.assertRaises(model_storage.UsedUpdateTrackerAfterRemovalError): + t1.export_update() + with self.assertRaises(model_storage.UsedUpdateTrackerAfterRemovalError): + t1.advance_checkpoint() + with self.assertRaises(KeyError): + mod.remove_update_tracker(t1) + + +class WrongAttributeTest(unittest.TestCase): + """Test case that verifies that wrong attributes are detected. + + In some the tests below we have to disable pytype checks since it also detects + the issue now that the code uses __slots__. + """ + + def test_variable(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_variable() + with self.assertRaises(AttributeError): + x.loer_bnd = 4 # pytype: disable=not-writable + + def test_linear_constraint(self) -> None: + mod = model.Model(name="test_model") + c = mod.add_linear_constraint() + with self.assertRaises(AttributeError): + c.uper_bound = 8 # pytype: disable=not-writable + + def test_objective(self) -> None: + mod = model.Model(name="test_model") + with self.assertRaises(AttributeError): + mod.objective.matimuze = True # pytype: disable=not-writable + + def test_model(self) -> None: + mod = model.Model(name="test_model") + with self.assertRaises(AttributeError): + mod.objectize = None # pytype: disable=not-writable + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/normalize.py b/ortools/math_opt/python/normalize.py new file mode 100644 index 0000000000..576dcd253b --- /dev/null +++ b/ortools/math_opt/python/normalize.py @@ -0,0 +1,77 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# A fork of net/proto2/contrib/pyutil/normalize.py. A lot of the code can be +# deleted because we do not support proto2 (no groups, no extension). Further, +# the code has been changed to not clear: +# * optional scalar fields at their default value. +# * durations +# * messages in a oneof + + +"""Utility functions for normalizing proto3 message objects in Python.""" +from google3.google.protobuf import duration_pb2 +from google.protobuf import descriptor +from google.protobuf import message + + +def math_opt_normalize_proto(protobuf_message: message.Message) -> None: + """Clears all non-duration submessages that are not in one_ofs. + + A message is considered `empty` if: + * every non-optional scalar fields has its default value, + * every optional scalar field is unset, + * every repeated/map fields is empty + * every oneof is unset, + * every duration field is unset + * all other message fields (singular, not oneof, not duration) are `empty`. + This function clears all `empty` fields from `message`. + + This is useful for testing. + + Args: + protobuf_message: The Message object to clear. + """ + for field, value in protobuf_message.ListFields(): + if field.type != field.TYPE_MESSAGE: + continue + if field.label == field.LABEL_REPEATED: + # Now the repeated case, recursively normalize each member. Note that + # there is no field presence for repeated fields, so we don't need to call + # ClearField(). + # + # Maps need to be handled specially. + if ( + field.message_type.has_options + and field.message_type.GetOptions().map_entry + ): + if ( + field.message_type.fields_by_number[2].type + == descriptor.FieldDescriptor.TYPE_MESSAGE + ): + for item in value.values(): + math_opt_normalize_proto(item) + # The remaining case is a regular repeated field (a list). + else: + for item in value: + math_opt_normalize_proto(item) + continue + # Last case, the non-repeated sub-message + math_opt_normalize_proto(value) + # If field value is empty, not a Duration, and not in a oneof, clear it. + if ( + not value.ListFields() + and field.message_type != duration_pb2.Duration.DESCRIPTOR + and field.containing_oneof is None + ): + protobuf_message.ClearField(field.name) diff --git a/ortools/math_opt/python/normalize_test.py b/ortools/math_opt/python/normalize_test.py new file mode 100644 index 0000000000..8c20353e33 --- /dev/null +++ b/ortools/math_opt/python/normalize_test.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test normalize for mathopt protos.""" + +from google3.net.proto2.contrib.pyutil import compare +import unittest +from ortools.math_opt import model_parameters_pb2 +from ortools.math_opt import model_pb2 +from ortools.math_opt import model_update_pb2 +from ortools.math_opt import parameters_pb2 +from ortools.math_opt import result_pb2 +from ortools.math_opt import sparse_containers_pb2 +from ortools.math_opt.python import normalize + + +class MathOptProtoAssertionsTest(unittest.TestCase, compare.Proto2Assertions): + def test_removes_empty_message(self) -> None: + model_with_empty_vars = model_pb2.ModelProto() + model_with_empty_vars.variables.SetInParent() + with self.assertRaisesRegex(AssertionError, ".*variables.*"): + self.assertProto2Equal(model_with_empty_vars, model_pb2.ModelProto()) + + normalize.math_opt_normalize_proto(model_with_empty_vars) + self.assertProto2Equal(model_with_empty_vars, model_pb2.ModelProto()) + + def test_keeps_nonempty_message(self) -> None: + model_with_vars = model_pb2.ModelProto() + model_with_vars.variables.ids[:] = [1, 3] + + expected = model_pb2.ModelProto() + expected.variables.ids[:] = [1, 3] + + normalize.math_opt_normalize_proto(model_with_vars) + self.assertProto2Equal(model_with_vars, expected) + + def test_keeps_optional_scalar_at_default_message(self) -> None: + objective = model_update_pb2.ObjectiveUpdatesProto(offset_update=0.0) + + normalize.math_opt_normalize_proto(objective) + + wrong = model_update_pb2.ObjectiveUpdatesProto() + with self.assertRaisesRegex(AssertionError, ".*offset_update.*"): + self.assertProto2Equal(objective, wrong) + + expected = model_update_pb2.ObjectiveUpdatesProto(offset_update=0.0) + self.assertProto2Equal(objective, expected) + + def test_recursive_cleanup(self) -> None: + update_rec_empty = model_update_pb2.ModelUpdateProto() + update_rec_empty.variable_updates.lower_bounds.SetInParent() + + normalize.math_opt_normalize_proto(update_rec_empty) + self.assertProto2Equal(update_rec_empty, model_update_pb2.ModelUpdateProto()) + + def test_duration_no_cleanup(self) -> None: + params = parameters_pb2.SolveParametersProto() + params.time_limit.SetInParent() + + with self.assertRaisesRegex(AssertionError, ".*time_limit.*"): + normalize.math_opt_normalize_proto(params) + self.assertProto2Equal(params, parameters_pb2.SolveParametersProto()) + + def test_repeated_scalar_no_cleanup(self) -> None: + vec = sparse_containers_pb2.SparseDoubleVectorProto() + vec.ids[:] = [0, 0] + normalize.math_opt_normalize_proto(vec) + + expected = sparse_containers_pb2.SparseDoubleVectorProto() + expected.ids[:] = [0, 0] + + self.assertProto2Equal(vec, expected) + + def test_reaches_into_map(self) -> None: + model = model_pb2.ModelProto() + model.quadratic_constraints[2].linear_terms.SetInParent() + normalize.math_opt_normalize_proto(model) + + expected = model_pb2.ModelProto() + expected.quadratic_constraints[2] # pylint: disable=pointless-statement + + self.assertProto2Equal(model, expected) + + def test_reaches_into_vector(self) -> None: + params = model_parameters_pb2.ModelSolveParametersProto() + params.solution_hints.add().variable_values.SetInParent() + normalize.math_opt_normalize_proto(params) + + expected = model_parameters_pb2.ModelSolveParametersProto() + expected.solution_hints.add() + + self.assertProto2Equal(params, expected) + + def test_oneof_is_not_cleared(self) -> None: + result = result_pb2.SolveResultProto() + result.gscip_output.SetInParent() + normalize.math_opt_normalize_proto(result) + + expected = result_pb2.SolveResultProto() + expected.gscip_output.SetInParent() + + self.assertProto2Equal(result, expected) + + def test_reaches_into_oneof(self) -> None: + result = result_pb2.SolveResultProto() + result.gscip_output.stats.SetInParent() + normalize.math_opt_normalize_proto(result) + + expected = result_pb2.SolveResultProto() + expected.gscip_output.SetInParent() + + self.assertProto2Equal(result, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/parameters.py b/ortools/math_opt/python/parameters.py new file mode 100644 index 0000000000..002ac77528 --- /dev/null +++ b/ortools/math_opt/python/parameters.py @@ -0,0 +1,455 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Configures the solving of an optimization model.""" + +import dataclasses +import datetime +import enum +from typing import Dict, Optional + +from ortools.pdlp import solvers_pb2 as pdlp_solvers_pb2 +from ortools.glop import parameters_pb2 as glop_parameters_pb2 +from ortools.gscip import gscip_pb2 +from ortools.math_opt import parameters_pb2 as math_opt_parameters_pb2 +from ortools.math_opt.solvers import glpk_pb2 +from ortools.math_opt.solvers import gurobi_pb2 +from ortools.math_opt.solvers import highs_pb2 +from ortools.math_opt.solvers import osqp_pb2 +from ortools.sat import sat_parameters_pb2 + + +@enum.unique +class SolverType(enum.Enum): + """The underlying solver to use. + + This must stay synchronized with math_opt_parameters_pb2.SolverTypeProto. + + Attributes: + GSCIP: Solving Constraint Integer Programs (SCIP) solver (third party). + Supports LP, MIP, and nonconvex integer quadratic problems. No dual data + for LPs is returned though. Prefer GLOP for LPs. + GUROBI: Gurobi solver (third party). Supports LP, MIP, and nonconvex integer + quadratic problems. Generally the fastest option, but has special + licensing, see go/gurobi-google for details. + GLOP: Google's Glop linear solver. Supports LP with primal and dual simplex + methods. + CP_SAT: Google's CP-SAT solver. Supports problems where all variables are + integer and bounded (or implied to be after presolve). Experimental + support to rescale and discretize problems with continuous variables. + MOE:begin_intracomment_strip + PDLP: Google's PDLP solver. Supports LP and convex diagonal quadratic + objectives. Uses first order methods rather than simplex. Can solve very + large problems. + MOE:end_intracomment_strip + GLPK: GNU Linear Programming Kit (GLPK) (third party). Supports MIP and LP. + Thread-safety: GLPK use thread-local storage for memory allocations. As a + consequence when using IncrementalSolver, the user must make sure that + instances are closed on the same thread as they are created or GLPK will + crash. To do so, use `with` or call IncrementalSolver#close(). It seems OK + to call IncrementalSolver#Solve() from another thread than the one used to + create the Solver but it is not documented by GLPK and should be avoided. + Of course these limitations do not apply to the solve() function that + recreates a new GLPK problem in the calling thread and destroys before + returning. When solving a LP with the presolver, a solution (and the + unbound rays) are only returned if an optimal solution has been found. + Else nothing is returned. See glpk-5.0/doc/glpk.pdf page #40 available + from glpk-5.0.tar.gz for details. + OSQP: The Operator Splitting Quadratic Program (OSQP) solver (third party). + Supports continuous problems with linear constraints and linear or convex + quadratic objectives. Uses a first-order method. + ECOS: The Embedded Conic Solver (ECOS) (third party). Supports LP and SOCP + problems. Uses interior point methods (barrier). + SCS: The Splitting Conic Solver (SCS) (third party). Supports LP and SOCP + problems. Uses a first-order method. + HIGHS: The HiGHS Solver (third party). Supports LP and MIP problems (convex + QPs are unimplemented). + SANTORINI: The Santorini Solver (first party). Supports MIP. Experimental, + do not use in production. + """ + + GSCIP = math_opt_parameters_pb2.SOLVER_TYPE_GSCIP + GUROBI = math_opt_parameters_pb2.SOLVER_TYPE_GUROBI + GLOP = math_opt_parameters_pb2.SOLVER_TYPE_GLOP + CP_SAT = math_opt_parameters_pb2.SOLVER_TYPE_CP_SAT + GLPK = math_opt_parameters_pb2.SOLVER_TYPE_GLPK + OSQP = math_opt_parameters_pb2.SOLVER_TYPE_OSQP + ECOS = math_opt_parameters_pb2.SOLVER_TYPE_ECOS + SCS = math_opt_parameters_pb2.SOLVER_TYPE_SCS + HIGHS = math_opt_parameters_pb2.SOLVER_TYPE_HIGHS + SANTORINI = math_opt_parameters_pb2.SOLVER_TYPE_SANTORINI + + +def solver_type_from_proto( + proto_value: math_opt_parameters_pb2.SolverTypeProto, +) -> Optional[SolverType]: + if proto_value == math_opt_parameters_pb2.SOLVER_TYPE_UNSPECIFIED: + return None + return SolverType(proto_value) + + +def solver_type_to_proto( + solver_type: Optional[SolverType], +) -> math_opt_parameters_pb2.SolverTypeProto: + if solver_type is None: + return math_opt_parameters_pb2.SOLVER_TYPE_UNSPECIFIED + return solver_type.value + + +@enum.unique +class LPAlgorithm(enum.Enum): + """Selects an algorithm for solving linear programs. + + Attributes: + * UNPSECIFIED: No algorithm is selected. + * PRIMAL_SIMPLEX: The (primal) simplex method. Typically can provide primal + and dual solutions, primal/dual rays on primal/dual unbounded problems, + and a basis. + * DUAL_SIMPLEX: The dual simplex method. Typically can provide primal and + dual solutions, primal/dual rays on primal/dual unbounded problems, and a + basis. + * BARRIER: The barrier method, also commonly called an interior point method + (IPM). Can typically give both primal and dual solutions. Some + implementations can also produce rays on unbounded/infeasible problems. A + basis is not given unless the underlying solver does "crossover" and + finishes with simplex. + * FIRST_ORDER: An algorithm based around a first-order method. These will + typically produce both primal and dual solutions, and potentially also + certificates of primal and/or dual infeasibility. First-order methods + typically will provide solutions with lower accuracy, so users should take + care to set solution quality parameters (e.g., tolerances) and to validate + solutions. + + This must stay synchronized with math_opt_parameters_pb2.LPAlgorithmProto. + """ + + PRIMAL_SIMPLEX = math_opt_parameters_pb2.LP_ALGORITHM_PRIMAL_SIMPLEX + DUAL_SIMPLEX = math_opt_parameters_pb2.LP_ALGORITHM_DUAL_SIMPLEX + BARRIER = math_opt_parameters_pb2.LP_ALGORITHM_BARRIER + FIRST_ORDER = math_opt_parameters_pb2.LP_ALGORITHM_FIRST_ORDER + + +def lp_algorithm_from_proto( + proto_value: math_opt_parameters_pb2.LPAlgorithmProto, +) -> Optional[LPAlgorithm]: + if proto_value == math_opt_parameters_pb2.LP_ALGORITHM_UNSPECIFIED: + return None + return LPAlgorithm(proto_value) + + +def lp_algorithm_to_proto( + lp_algorithm: Optional[LPAlgorithm], +) -> math_opt_parameters_pb2.LPAlgorithmProto: + if lp_algorithm is None: + return math_opt_parameters_pb2.LP_ALGORITHM_UNSPECIFIED + return lp_algorithm.value + + +@enum.unique +class Emphasis(enum.Enum): + """Effort level applied to an optional task while solving (see SolveParameters for use). + + - OFF: disable this task. + - LOW: apply reduced effort. + - MEDIUM: typically the default setting (unless the default is off). + - HIGH: apply extra effort beyond MEDIUM. + - VERY_HIGH: apply the maximum effort. + + Typically used as Optional[Emphasis]. It used to configure a solver feature as + follows: + * If a solver doesn't support the feature, only None will always be valid, + any other setting will give an invalid argument error (some solvers may + also accept OFF). + * If the solver supports the feature: + - When set to None, the underlying default is used. + - When the feature cannot be turned off, OFF will produce an error. + - If the feature is enabled by default, the solver default is typically + mapped to MEDIUM. + - If the feature is supported, LOW, MEDIUM, HIGH, and VERY HIGH will never + give an error, and will map onto their best match. + + This must stay synchronized with math_opt_parameters_pb2.EmphasisProto. + """ + + OFF = math_opt_parameters_pb2.EMPHASIS_OFF + LOW = math_opt_parameters_pb2.EMPHASIS_LOW + MEDIUM = math_opt_parameters_pb2.EMPHASIS_MEDIUM + HIGH = math_opt_parameters_pb2.EMPHASIS_HIGH + VERY_HIGH = math_opt_parameters_pb2.EMPHASIS_VERY_HIGH + + +def emphasis_from_proto( + proto_value: math_opt_parameters_pb2.EmphasisProto, +) -> Optional[Emphasis]: + if proto_value == math_opt_parameters_pb2.EMPHASIS_UNSPECIFIED: + return None + return Emphasis(proto_value) + + +def emphasis_to_proto( + emphasis: Optional[Emphasis], +) -> math_opt_parameters_pb2.EmphasisProto: + if emphasis is None: + return math_opt_parameters_pb2.EMPHASIS_UNSPECIFIED + return emphasis.value + + +@dataclasses.dataclass +class GurobiParameters: + """Gurobi specific parameters for solving. + + See https://www.gurobi.com/documentation/9.1/refman/parameters.html for a list + of possible parameters. + + Example use: + gurobi=GurobiParameters(); + gurobi.param_values["BarIterLimit"] = "10"; + + With Gurobi, the order that parameters are applied can have an impact in rare + situations. Parameters are applied in the following order: + * LogToConsole is set from SolveParameters.enable_output. + * Any common parameters not overwritten by GurobiParameters. + * param_values in iteration order (insertion order). + We set LogToConsole first because setting other parameters can generate + output. + """ + + param_values: Dict[str, str] = dataclasses.field(default_factory=dict) + + def to_proto(self) -> gurobi_pb2.GurobiParametersProto: + return gurobi_pb2.GurobiParametersProto( + parameters=[ + gurobi_pb2.GurobiParametersProto.Parameter(name=key, value=val) + for key, val in self.param_values.items() + ] + ) + + +@dataclasses.dataclass +class GlpkParameters: + """GLPK specific parameters for solving. + + Fields are optional to enable to capture user intention; if they set + explicitly a value to then no generic solve parameters will overwrite this + parameter. User specified solver specific parameters have priority on generic + parameters. + + Attributes: + compute_unbound_rays_if_possible: Compute the primal or dual unbound ray + when the variable (structural or auxiliary) causing the unboundness is + identified (see glp_get_unbnd_ray()). The unset value is equivalent to + false. Rays are only available when solving linear programs, they are not + available for MIPs. On top of that they are only available when using a + simplex algorithm with the presolve disabled. A primal ray can only be + built if the chosen LP algorithm is LPAlgorithm.PRIMAL_SIMPLEX. Same for a + dual ray and LPAlgorithm.DUAL_SIMPLEX. The computation involves the basis + factorization to be available which may lead to extra computations/errors. + """ + + compute_unbound_rays_if_possible: Optional[bool] = None + + def to_proto(self) -> glpk_pb2.GlpkParametersProto: + return glpk_pb2.GlpkParametersProto( + compute_unbound_rays_if_possible=self.compute_unbound_rays_if_possible + ) + + +@dataclasses.dataclass +class SolveParameters: + """Parameters to control a single solve. + + If a value is set in both common and solver specific field (e.g. gscip), the + solver specific setting is used. + + Solver specific parameters for solvers other than the one in use are ignored. + + Parameters that depends on the model (e.g. branching priority is set for each + variable) are passed in ModelSolveParameters. + + See solve() and IncrementalSolver.solve() in solve.py for more details. + + Attributes: + time_limit: The maximum time a solver should spend on the problem, or if + None, then the time limit is infinite. This value is not a hard limit, + solve time may slightly exceed this value. This parameter is always passed + to the underlying solver, the solver default is not used. + iteration_limit: Limit on the iterations of the underlying algorithm (e.g. + simplex pivots). The specific behavior is dependent on the solver and + algorithm used, but often can give a deterministic solve limit (further + configuration may be needed, e.g. one thread). Typically supported by LP, + QP, and MIP solvers, but for MIP solvers see also node_limit. + node_limit: Limit on the number of subproblems solved in enumerative search + (e.g. branch and bound). For many solvers this can be used to + deterministically limit computation (further configuration may be needed, + e.g. one thread). Typically for MIP solvers, see also iteration_limit. + cutoff_limit: The solver stops early if it can prove there are no primal + solutions at least as good as cutoff. On an early stop, the solver returns + TerminationReason.NO_SOLUTION_FOUND and with Limit.CUTOFF and is not + required to give any extra solution information. Has no effect on the + return value if there is no early stop. It is recommended that you use a + tolerance if you want solutions with objective exactly equal to cutoff to + be returned. See the user guide for more details and a comparison with + best_bound_limit. + objective_limit: The solver stops early as soon as it finds a solution at + least this good, with TerminationReason.FEASIBLE and Limit.OBJECTIVE. + best_bound_limit: The solver stops early as soon as it proves the best bound + is at least this good, with TerminationReason of FEASIBLE or + NO_SOLUTION_FOUND and Limit.OBJECTIVE. See the user guide for more details + and a comparison with cutoff_limit. + solution_limit: The solver stops early after finding this many feasible + solutions, with TerminationReason.FEASIBLE and Limit.SOLUTION. Must be + greater than zero if set. It is often used get the solver to stop on the + first feasible solution found. Note that there is no guarantee on the + objective value for any of the returned solutions. Solvers will typically + not return more solutions than the solution limit, but this is not + enforced by MathOpt, see also b/214041169. Currently supported for Gurobi + and SCIP, and for CP-SAT only with value 1. + enable_output: If the solver should print out its log messages. + threads: An integer >= 1, how many threads to use when solving. + random_seed: Seed for the pseudo-random number generator in the underlying + solver. Note that valid values depend on the actual solver: + * Gurobi: [0:GRB_MAXINT] (which as of Gurobi 9.0 is 2x10^9). + * GSCIP: [0:2147483647] (which is MAX_INT or kint32max or 2^31-1). + * GLOP: [0:2147483647] (same as above). + In all cases, the solver will receive a value equal to: + MAX(0, MIN(MAX_VALID_VALUE_FOR_SOLVER, random_seed)). + absolute_gap_tolerance: An absolute optimality tolerance (primarily) for MIP + solvers. The absolute GAP is the absolute value of the difference between: + * the objective value of the best feasible solution found, + * the dual bound produced by the search. + The solver can stop once the absolute GAP is at most + absolute_gap_tolerance (when set), and return TerminationReason.OPTIMAL. + Must be >= 0 if set. See also relative_gap_tolerance. + relative_gap_tolerance: A relative optimality tolerance (primarily) for MIP + solvers. The relative GAP is a normalized version of the absolute GAP + (defined on absolute_gap_tolerance), where the normalization is + solver-dependent, e.g. the absolute GAP divided by the objective value of + the best feasible solution found. The solver can stop once the relative + GAP is at most relative_gap_tolerance (when set), and return + TerminationReason.OPTIMAL. Must be >= 0 if set. See also + absolute_gap_tolerance. + solution_pool_size: Maintain up to `solution_pool_size` solutions while + searching. The solution pool generally has two functions: + * For solvers that can return more than one solution, this limits how + many solutions will be returned. + * Some solvers may run heuristics using solutions from the solution + pool, so changing this value may affect the algorithm's path. + To force the solver to fill the solution pool, e.g. with the n best + solutions, requires further, solver specific configuration. + lp_algorithm: The algorithm for solving a linear program. If UNSPECIFIED, + use the solver default algorithm. For problems that are not linear + programs but where linear programming is a subroutine, solvers may use + this value. E.g. MIP solvers will typically use this for the root LP solve + only (and use dual simplex otherwise). + presolve: Effort on simplifying the problem before starting the main + algorithm (e.g. simplex). + cuts: Effort on getting a stronger LP relaxation (MIP only). Note that in + some solvers, disabling cuts may prevent callbacks from having a chance to + add cuts at MIP_NODE. + heuristics: Effort in finding feasible solutions beyond those encountered in + the complete search procedure. + scaling: Effort in rescaling the problem to improve numerical stability. + gscip: GSCIP specific solve parameters. + gurobi: Gurobi specific solve parameters. + glop: Glop specific solve parameters. + cp_sat: CP-SAT specific solve parameters. + pdlp: PDLP specific solve parameters. + osqp: OSQP specific solve parameters. Users should prefer the generic + MathOpt parameters over OSQP-level parameters, when available: - Prefer + SolveParameters.enable_output to OsqpSettingsProto.verbose. - Prefer + SolveParameters.time_limit to OsqpSettingsProto.time_limit. - Prefer + SolveParameters.iteration_limit to OsqpSettingsProto.iteration_limit. - If + a less granular configuration is acceptable, prefer + SolveParameters.scaling to OsqpSettingsProto. + glpk: GLPK specific solve parameters. + highs: HiGHS specific solve parameters. + """ # fmt: skip + + time_limit: Optional[datetime.timedelta] = None + iteration_limit: Optional[int] = None + node_limit: Optional[int] = None + cutoff_limit: Optional[float] = None + objective_limit: Optional[float] = None + best_bound_limit: Optional[float] = None + solution_limit: Optional[int] = None + enable_output: bool = False + threads: Optional[int] = None + random_seed: Optional[int] = None + absolute_gap_tolerance: Optional[float] = None + relative_gap_tolerance: Optional[float] = None + solution_pool_size: Optional[int] = None + lp_algorithm: Optional[LPAlgorithm] = None + presolve: Optional[Emphasis] = None + cuts: Optional[Emphasis] = None + heuristics: Optional[Emphasis] = None + scaling: Optional[Emphasis] = None + gscip: gscip_pb2.GScipParameters = dataclasses.field( + default_factory=gscip_pb2.GScipParameters + ) + gurobi: GurobiParameters = dataclasses.field(default_factory=GurobiParameters) + glop: glop_parameters_pb2.GlopParameters = dataclasses.field( + default_factory=glop_parameters_pb2.GlopParameters + ) + cp_sat: sat_parameters_pb2.SatParameters = dataclasses.field( + default_factory=sat_parameters_pb2.SatParameters + ) + osqp: osqp_pb2.OsqpSettingsProto = dataclasses.field( + default_factory=osqp_pb2.OsqpSettingsProto + ) + glpk: GlpkParameters = dataclasses.field(default_factory=GlpkParameters) + highs: highs_pb2.HighsOptionsProto = dataclasses.field( + default_factory=highs_pb2.HighsOptionsProto + ) + + def to_proto(self) -> math_opt_parameters_pb2.SolveParametersProto: + """Returns a protocol buffer equivalent to this.""" + result = math_opt_parameters_pb2.SolveParametersProto( + enable_output=self.enable_output, + lp_algorithm=lp_algorithm_to_proto(self.lp_algorithm), + presolve=emphasis_to_proto(self.presolve), + cuts=emphasis_to_proto(self.cuts), + heuristics=emphasis_to_proto(self.heuristics), + scaling=emphasis_to_proto(self.scaling), + gscip=self.gscip, + gurobi=self.gurobi.to_proto(), + glop=self.glop, + cp_sat=self.cp_sat, + osqp=self.osqp, + glpk=self.glpk.to_proto(), + highs=self.highs, + ) + if self.time_limit is not None: + result.time_limit.FromTimedelta(self.time_limit) + if self.iteration_limit is not None: + result.iteration_limit = self.iteration_limit + if self.node_limit is not None: + result.node_limit = self.node_limit + if self.cutoff_limit is not None: + result.cutoff_limit = self.cutoff_limit + if self.objective_limit is not None: + result.objective_limit = self.objective_limit + if self.best_bound_limit is not None: + result.best_bound_limit = self.best_bound_limit + if self.solution_limit is not None: + result.solution_limit = self.solution_limit + if self.threads is not None: + result.threads = self.threads + if self.random_seed is not None: + result.random_seed = self.random_seed + if self.absolute_gap_tolerance is not None: + result.absolute_gap_tolerance = self.absolute_gap_tolerance + if self.relative_gap_tolerance is not None: + result.relative_gap_tolerance = self.relative_gap_tolerance + if self.solution_pool_size is not None: + result.solution_pool_size = self.solution_pool_size + return result diff --git a/ortools/math_opt/python/parameters_test.py b/ortools/math_opt/python/parameters_test.py new file mode 100644 index 0000000000..29b7aa3160 --- /dev/null +++ b/ortools/math_opt/python/parameters_test.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +from typing import Any + +import unittest +from google3.testing.pybase import parameterized +from ortools.pdlp import solvers_pb2 as pdlp_solvers_pb2 +from ortools.glop import parameters_pb2 as glop_parameters_pb2 +from ortools.gscip import gscip_pb2 +from ortools.math_opt import parameters_pb2 as math_opt_parameters_pb2 +from ortools.math_opt.python import parameters +from ortools.math_opt.python.testing import compare_proto +from ortools.math_opt.solvers import glpk_pb2 +from ortools.math_opt.solvers import gurobi_pb2 +from ortools.math_opt.solvers import highs_pb2 +from ortools.math_opt.solvers import osqp_pb2 +from ortools.sat import sat_parameters_pb2 + + +class GurobiParameters(unittest.TestCase): + def test_to_proto(self) -> None: + gurobi_proto = parameters.GurobiParameters( + param_values={"x": "dog", "ab": "7"} + ).to_proto() + expected_proto = gurobi_pb2.GurobiParametersProto( + parameters=[ + gurobi_pb2.GurobiParametersProto.Parameter(name="x", value="dog"), + gurobi_pb2.GurobiParametersProto.Parameter(name="ab", value="7"), + ] + ) + self.assertEqual(expected_proto, gurobi_proto) + + +class GlpkParameters(unittest.TestCase): + def test_to_proto(self) -> None: + # Test with `optional bool` set to true. + glpk_proto = parameters.GlpkParameters( + compute_unbound_rays_if_possible=True + ).to_proto() + expected_proto = glpk_pb2.GlpkParametersProto( + compute_unbound_rays_if_possible=True + ) + self.assertEqual(glpk_proto, expected_proto) + + # Test with `optional bool` set to false. + glpk_proto = parameters.GlpkParameters( + compute_unbound_rays_if_possible=False + ).to_proto() + expected_proto = glpk_pb2.GlpkParametersProto( + compute_unbound_rays_if_possible=False + ) + self.assertEqual(glpk_proto, expected_proto) + + # Test with `optional bool` unset. + glpk_proto = parameters.GlpkParameters().to_proto() + expected_proto = glpk_pb2.GlpkParametersProto() + self.assertEqual(glpk_proto, expected_proto) + + +class ProtoRoundTrip(unittest.TestCase): + def test_solver_type_round_trip(self) -> None: + for solver_type in parameters.SolverType: + self.assertEqual( + solver_type, + parameters.solver_type_from_proto( + parameters.solver_type_to_proto(solver_type) + ), + ) + self.assertEqual( + math_opt_parameters_pb2.SOLVER_TYPE_UNSPECIFIED, + parameters.solver_type_to_proto(None), + ) + self.assertIsNone( + parameters.solver_type_from_proto( + math_opt_parameters_pb2.SOLVER_TYPE_UNSPECIFIED + ) + ) + + def test_lp_algorithm_round_trip(self) -> None: + for lp_alg in parameters.LPAlgorithm: + self.assertEqual( + lp_alg, + parameters.lp_algorithm_from_proto( + parameters.lp_algorithm_to_proto(lp_alg) + ), + ) + self.assertEqual( + math_opt_parameters_pb2.LP_ALGORITHM_UNSPECIFIED, + parameters.lp_algorithm_to_proto(None), + ) + self.assertIsNone( + parameters.lp_algorithm_from_proto( + math_opt_parameters_pb2.LP_ALGORITHM_UNSPECIFIED + ) + ) + + def test_emphasis_round_trip(self) -> None: + for emph in parameters.Emphasis: + self.assertEqual( + emph, + parameters.emphasis_from_proto(parameters.emphasis_to_proto(emph)), + ) + self.assertEqual( + math_opt_parameters_pb2.EMPHASIS_UNSPECIFIED, + parameters.emphasis_to_proto(None), + ) + self.assertIsNone( + parameters.emphasis_from_proto(math_opt_parameters_pb2.EMPHASIS_UNSPECIFIED) + ) + + +class SolveParametersTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase): + """Test case for tests of SolveParameters.""" + + def test_common_to_proto(self) -> None: + params = parameters.SolveParameters( + time_limit=datetime.timedelta(seconds=10), + iteration_limit=7, + node_limit=3, + cutoff_limit=9.5, + objective_limit=10.5, + best_bound_limit=11.5, + solution_limit=2, + enable_output=True, + threads=3, + random_seed=12, + absolute_gap_tolerance=1.3, + relative_gap_tolerance=0.05, + solution_pool_size=17, + lp_algorithm=parameters.LPAlgorithm.BARRIER, + presolve=parameters.Emphasis.OFF, + cuts=parameters.Emphasis.LOW, + heuristics=parameters.Emphasis.MEDIUM, + scaling=parameters.Emphasis.HIGH, + ) + expected = math_opt_parameters_pb2.SolveParametersProto( + iteration_limit=7, + node_limit=3, + cutoff_limit=9.5, + objective_limit=10.5, + best_bound_limit=11.5, + solution_limit=2, + enable_output=True, + threads=3, + random_seed=12, + absolute_gap_tolerance=1.3, + relative_gap_tolerance=0.05, + solution_pool_size=17, + lp_algorithm=math_opt_parameters_pb2.LP_ALGORITHM_BARRIER, + presolve=math_opt_parameters_pb2.EMPHASIS_OFF, + cuts=math_opt_parameters_pb2.EMPHASIS_LOW, + heuristics=math_opt_parameters_pb2.EMPHASIS_MEDIUM, + scaling=math_opt_parameters_pb2.EMPHASIS_HIGH, + ) + expected.time_limit.FromTimedelta(datetime.timedelta(seconds=10)) + self.assert_protos_equiv(expected, params.to_proto()) + + def test_to_proto_with_none(self) -> None: + params = parameters.SolveParameters() + expected = math_opt_parameters_pb2.SolveParametersProto() + self.assert_protos_equiv(expected, params.to_proto()) + + @parameterized.named_parameters( + ( + "gscip", + "gscip", + gscip_pb2.GScipParameters(print_detailed_solving_stats=True), + ), + ( + "glop", + "glop", + glop_parameters_pb2.GlopParameters(refactorization_threshold=1e-5), + ), + ( + "gurobi", + "gurobi", + parameters.GurobiParameters(param_values={"NodeLimit": "30"}), + ), + ( + "cp_sat", + "cp_sat", + sat_parameters_pb2.SatParameters(random_branches_ratio=0.5), + ), + ("osqp", "osqp", osqp_pb2.OsqpSettingsProto(sigma=1.2)), + ( + "glpk", + "glpk", + parameters.GlpkParameters(compute_unbound_rays_if_possible=True), + ), + ( + "highs", + "highs", + highs_pb2.HighsOptionsProto(bool_options={"solve_relaxation": True}), + ), + ) + def test_to_proto_with_specifics( + self, field: str, solver_specific_param: Any + ) -> None: + solve_params = parameters.SolveParameters(threads=3) + setattr(solve_params, field, solver_specific_param) + expected = math_opt_parameters_pb2.SolveParametersProto(threads=3) + proto_solver_specific_param = ( + solver_specific_param.to_proto() + if field in ("gurobi", "glpk") + else solver_specific_param + ) + getattr(expected, field).CopyFrom(proto_solver_specific_param) + self.assert_protos_equiv(expected, solve_params.to_proto()) + + def test_to_proto_no_specifics(self) -> None: + solve_params = parameters.SolveParameters(threads=3) + expected = math_opt_parameters_pb2.SolveParametersProto(threads=3) + self.assert_protos_equiv(expected, solve_params.to_proto()) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/result.py b/ortools/math_opt/python/result.py new file mode 100644 index 0000000000..cab932f249 --- /dev/null +++ b/ortools/math_opt/python/result.py @@ -0,0 +1,1006 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The output from solving a mathematical optimization problem from model.py.""" +import dataclasses +import datetime +import enum +from typing import Dict, Iterable, List, Optional, overload + +from ortools.gscip import gscip_pb2 +from ortools.math_opt import result_pb2 +from ortools.math_opt.python import model +from ortools.math_opt.python import solution +from ortools.math_opt.solvers import osqp_pb2 + +_NO_DUAL_SOLUTION_ERROR = ( + "Best solution does not have an associated dual feasible solution." +) +_NO_BASIS_ERROR = "Best solution does not have an associated basis." + + +@enum.unique +class FeasibilityStatus(enum.Enum): + """Problem feasibility status as claimed by the solver. + + (solver is not required to return a certificate for the claim.) + + Attributes: + UNDETERMINED: Solver does not claim a status. + FEASIBLE: Solver claims the problem is feasible. + INFEASIBLE: Solver claims the problem is infeasible. + """ + + UNDETERMINED = result_pb2.FEASIBILITY_STATUS_UNDETERMINED + FEASIBLE = result_pb2.FEASIBILITY_STATUS_FEASIBLE + INFEASIBLE = result_pb2.FEASIBILITY_STATUS_INFEASIBLE + + +@dataclasses.dataclass(frozen=True) +class ProblemStatus: + """Feasibility status of the primal problem and its dual (or dual relaxation). + + Statuses are as claimed by the solver and a dual relaxation is the dual of a + continuous relaxation for the original problem (e.g. the LP relaxation of a + MIP). The solver is not required to return a certificate for the feasibility + or infeasibility claims (e.g. the solver may claim primal feasibility without + returning a primal feasible solutuion). This combined status gives a + comprehensive description of a solver's claims about feasibility and + unboundedness of the solved problem. For instance, + * a feasible status for primal and dual problems indicates the primal is + feasible and bounded and likely has an optimal solution (guaranteed for + problems without non-linear constraints). + * a primal feasible and a dual infeasible status indicates the primal + problem is unbounded (i.e. has arbitrarily good solutions). + Note that a dual infeasible status by itself (i.e. accompanied by an + undetermined primal status) does not imply the primal problem is unbounded as + we could have both problems be infeasible. Also, while a primal and dual + feasible status may imply the existence of an optimal solution, it does not + guarantee the solver has actually found such optimal solution. + + Attributes: + primal_status: Status for the primal problem. + dual_status: Status for the dual problem (or for the dual of a continuous + relaxation). + primal_or_dual_infeasible: If true, the solver claims the primal or dual + problem is infeasible, but it does not know which (or if both are + infeasible). Can be true only when primal_problem_status = + dual_problem_status = kUndetermined. This extra information is often + needed when preprocessing determines there is no optimal solution to the + problem (but can't determine if it is due to infeasibility, unboundedness, + or both). + """ + + primal_status: FeasibilityStatus = FeasibilityStatus.UNDETERMINED + dual_status: FeasibilityStatus = FeasibilityStatus.UNDETERMINED + primal_or_dual_infeasible: bool = False + + def to_proto(self) -> result_pb2.ProblemStatusProto: + """Returns an equivalent proto for a problem status.""" + return result_pb2.ProblemStatusProto( + primal_status=self.primal_status.value, + dual_status=self.dual_status.value, + primal_or_dual_infeasible=self.primal_or_dual_infeasible, + ) + + +def parse_problem_status(proto: result_pb2.ProblemStatusProto) -> ProblemStatus: + """Returns an equivalent ProblemStatus from the input proto.""" + primal_status_proto = proto.primal_status + if primal_status_proto == result_pb2.FEASIBILITY_STATUS_UNSPECIFIED: + raise ValueError("Primal feasibility status should not be UNSPECIFIED") + dual_status_proto = proto.dual_status + if dual_status_proto == result_pb2.FEASIBILITY_STATUS_UNSPECIFIED: + raise ValueError("Dual feasibility status should not be UNSPECIFIED") + return ProblemStatus( + primal_status=FeasibilityStatus(primal_status_proto), + dual_status=FeasibilityStatus(dual_status_proto), + primal_or_dual_infeasible=proto.primal_or_dual_infeasible, + ) + + +@dataclasses.dataclass(frozen=True) +class ObjectiveBounds: + """Bounds on the optimal objective value. + + MOE:begin_intracomment_strip + See go/mathopt-objective-bounds for more details. + MOE:end_intracomment_strip + + Attributes: + primal_bound: Solver claims there exists a primal solution that is + numerically feasible (i.e. feasible up to the solvers tolerance), and + whose objective value is primal_bound. + + The optimal value is equal or better (smaller for min objectives and + larger for max objectives) than primal_bound, but only up to + solver-tolerances. + + MOE:begin_intracomment_strip + See go/mathopt-objective-bounds for more details. + MOE:end_intracomment_strip + dual_bound: Solver claims there exists a dual solution that is numerically + feasible (i.e. feasible up to the solvers tolerance), and whose objective + value is dual_bound. + + For MIP solvers, the associated dual problem may be some continuous + relaxation (e.g. LP relaxation), but it is often an implicitly defined + problem that is a complex consequence of the solvers execution. For both + continuous and MIP solvers, the optimal value is equal or worse (larger + for min objective and smaller for max objectives) than dual_bound, but + only up to solver-tolerances. Some continuous solvers provide a + numerically safer dual bound through solver's specific output (e.g. for + PDLP, pdlp_output.convergence_information.corrected_dual_objective). + + MOE:begin_intracomment_strip + See go/mathopt-objective-bounds for more details. + MOE:end_intracomment_strip + """ # fmt: skip + + primal_bound: float = 0.0 + dual_bound: float = 0.0 + + def to_proto(self) -> result_pb2.ObjectiveBoundsProto: + """Returns an equivalent proto for objective bounds.""" + return result_pb2.ObjectiveBoundsProto( + primal_bound=self.primal_bound, dual_bound=self.dual_bound + ) + + +def parse_objective_bounds( + proto: result_pb2.ObjectiveBoundsProto, +) -> ObjectiveBounds: + """Returns an equivalent ObjectiveBounds from the input proto.""" + return ObjectiveBounds(primal_bound=proto.primal_bound, dual_bound=proto.dual_bound) + + +@dataclasses.dataclass +class SolveStats: + """Problem statuses and solve statistics returned by the solver. + + Attributes: + solve_time: Elapsed wall clock time as measured by math_opt, roughly the + time inside solve(). Note: this does not include work done building the + model. + simplex_iterations: Simplex iterations. + barrier_iterations: Barrier iterations. + first_order_iterations: First order iterations. + node_count: Node count. + """ + + solve_time: datetime.timedelta = datetime.timedelta() + simplex_iterations: int = 0 + barrier_iterations: int = 0 + first_order_iterations: int = 0 + node_count: int = 0 + + def to_proto(self) -> result_pb2.SolveStatsProto: + """Returns an equivalent proto for a solve stats.""" + result = result_pb2.SolveStatsProto( + simplex_iterations=self.simplex_iterations, + barrier_iterations=self.barrier_iterations, + first_order_iterations=self.first_order_iterations, + node_count=self.node_count, + ) + result.solve_time.FromTimedelta(self.solve_time) + return result + + +def parse_solve_stats(proto: result_pb2.SolveStatsProto) -> SolveStats: + """Returns an equivalent SolveStats from the input proto.""" + result = SolveStats() + result.solve_time = proto.solve_time.ToTimedelta() + result.simplex_iterations = proto.simplex_iterations + result.barrier_iterations = proto.barrier_iterations + result.first_order_iterations = proto.first_order_iterations + result.node_count = proto.node_count + return result + + +@enum.unique +class TerminationReason(enum.Enum): + """The reason a solve of a model terminated. + + These reasons are typically as reported by the underlying solver, e.g. we do + not attempt to verify the precision of the solution returned. + + The values are: + * OPTIMAL: A provably optimal solution (up to numerical tolerances) has + been found. + * INFEASIBLE: The primal problem has no feasible solutions. + * UNBOUNDED: The primal problem is feasible and arbitrarily good solutions + can be found along a primal ray. + * INFEASIBLE_OR_UNBOUNDED: The primal problem is either infeasible or + unbounded. More details on the problem status may be available in + solve_stats.problem_status. Note that Gurobi's unbounded status may be + mapped here as explained in + go/mathopt-solver-specific#gurobi-inf-or-unb. + * IMPRECISE: The problem was solved to one of the criteria above (Optimal, + Infeasible, Unbounded, or InfeasibleOrUnbounded), but one or more + tolerances was not met. Some primal/dual solutions/rays may be present, + but either they will be slightly infeasible, or (if the problem was + nearly optimal) their may be a gap between the best solution objective + and best objective bound. + + Users can still query primal/dual solutions/rays and solution stats, + but they are responsible for dealing with the numerical imprecision. + * FEASIBLE: The optimizer reached some kind of limit and a primal feasible + solution is returned. See SolveResultProto.limit_detail for detailed + description of the kind of limit that was reached. + * NO_SOLUTION_FOUND: The optimizer reached some kind of limit and it did + not find a primal feasible solution. See SolveResultProto.limit_detail + for detailed description of the kind of limit that was reached. + * NUMERICAL_ERROR: The algorithm stopped because it encountered + unrecoverable numerical error. No solution information is present. + * OTHER_ERROR: The algorithm stopped because of an error not covered by one + of the statuses defined above. No solution information is present. + """ + + OPTIMAL = result_pb2.TERMINATION_REASON_OPTIMAL + INFEASIBLE = result_pb2.TERMINATION_REASON_INFEASIBLE + UNBOUNDED = result_pb2.TERMINATION_REASON_UNBOUNDED + INFEASIBLE_OR_UNBOUNDED = result_pb2.TERMINATION_REASON_INFEASIBLE_OR_UNBOUNDED + IMPRECISE = result_pb2.TERMINATION_REASON_IMPRECISE + FEASIBLE = result_pb2.TERMINATION_REASON_FEASIBLE + NO_SOLUTION_FOUND = result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND + NUMERICAL_ERROR = result_pb2.TERMINATION_REASON_NUMERICAL_ERROR + OTHER_ERROR = result_pb2.TERMINATION_REASON_OTHER_ERROR + + +@enum.unique +class Limit(enum.Enum): + """The optimizer reached a limit, partial solution information may be present. + + Values are: + * UNDETERMINED: The underlying solver does not expose which limit was + reached. + * ITERATION: An iterative algorithm stopped after conducting the + maximum number of iterations (e.g. simplex or barrier iterations). + * TIME: The algorithm stopped after a user-specified amount of + computation time. + * NODE: A branch-and-bound algorithm stopped because it explored a + maximum number of nodes in the branch-and-bound tree. + * SOLUTION: The algorithm stopped because it found the required + number of solutions. This is often used in MIPs to get the solver to + return the first feasible solution it encounters. + * MEMORY: The algorithm stopped because it ran out of memory. + * OBJECTIVE: The algorithm stopped because it found a solution better + than a minimum limit set by the user. + * NORM: The algorithm stopped because the norm of an iterate became + too large. + * INTERRUPTED: The algorithm stopped because of an interrupt signal or a + user interrupt request. + * SLOW_PROGRESS: The algorithm stopped because it was unable to continue + making progress towards the solution. + * OTHER: The algorithm stopped due to a limit not covered by one of the + above. Note that UNDETERMINED is used when the reason cannot be + determined, and OTHER is used when the reason is known but does not fit + into any of the above alternatives. + """ + + UNDETERMINED = result_pb2.LIMIT_UNDETERMINED + ITERATION = result_pb2.LIMIT_ITERATION + TIME = result_pb2.LIMIT_TIME + NODE = result_pb2.LIMIT_NODE + SOLUTION = result_pb2.LIMIT_SOLUTION + MEMORY = result_pb2.LIMIT_MEMORY + OBJECTIVE = result_pb2.LIMIT_OBJECTIVE + NORM = result_pb2.LIMIT_NORM + INTERRUPTED = result_pb2.LIMIT_INTERRUPTED + SLOW_PROGRESS = result_pb2.LIMIT_SLOW_PROGRESS + OTHER = result_pb2.LIMIT_OTHER + + +@dataclasses.dataclass +class Termination: + """An explanation of why the solver stopped. + + Attributes: + reason: Why the solver stopped, e.g. it found a provably optimal solution. + Additional information in `limit` when value is FEASIBLE or + NO_SOLUTION_FOUND, see `limit` for details. + limit: If the solver stopped early, what caused it to stop. Have value + UNSPECIFIED when reason is not NO_SOLUTION_FOUND or FEASIBLE. May still be + UNSPECIFIED when reason is NO_SOLUTION_FOUND or FEASIBLE, some solvers + cannot fill this in. + detail: Additional, information beyond reason about why the solver stopped, + typically solver specific. + problem_status: Feasibility statuses for primal and dual problems. + objective_bounds: Bounds on the optimal objective value. + """ + + reason: TerminationReason = TerminationReason.OPTIMAL + limit: Optional[Limit] = None + detail: str = "" + problem_status: ProblemStatus = ProblemStatus() + objective_bounds: ObjectiveBounds = ObjectiveBounds() + + +def parse_termination( + termination_proto: result_pb2.TerminationProto, +) -> Termination: + """Returns a Termination that is equivalent to termination_proto.""" + reason_proto = termination_proto.reason + limit_proto = termination_proto.limit + if reason_proto == result_pb2.TERMINATION_REASON_UNSPECIFIED: + raise ValueError("Termination reason should not be UNSPECIFIED") + reason_is_limit = ( + reason_proto == result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND + ) or (reason_proto == result_pb2.TERMINATION_REASON_FEASIBLE) + limit_set = limit_proto != result_pb2.LIMIT_UNSPECIFIED + if reason_is_limit != limit_set: + raise ValueError( + f"Termination limit (={limit_proto})) should take value other than " + f"UNSPECIFIED if and only if termination reason (={reason_proto}) is " + "FEASIBLE or NO_SOLUTION_FOUND" + ) + termination = Termination() + termination.reason = TerminationReason(reason_proto) + termination.limit = Limit(limit_proto) if limit_set else None + termination.detail = termination_proto.detail + termination.problem_status = parse_problem_status(termination_proto.problem_status) + termination.objective_bounds = parse_objective_bounds( + termination_proto.objective_bounds + ) + return termination + + +@dataclasses.dataclass +class SolveResult: + """The result of solving an optimization problem defined by a Model. + + We attempt to return as much solution information (primal_solutions, + primal_rays, dual_solutions, dual_rays) as each underlying solver will provide + given its return status. Differences in the underlying solvers result in a + weak contract on what fields will be populated for a given termination + reason. This is discussed in detail in termination_reasons.md, and the most + important points are summarized below: + * When the termination reason is optimal, there will be at least one primal + solution provided that will be feasible up to the underlying solver's + tolerances. + * Dual solutions are only given for convex optimization problems (e.g. + linear programs, not integer programs). + * A basis is only given for linear programs when solved by the simplex + method (e.g., not with PDLP). + * Solvers have widely varying support for returning primal and dual rays. + E.g. a termination_reason of unbounded does not ensure that a feasible + solution or a primal ray is returned, check termination_reasons.md for + solver specific guarantees if this is needed. Further, many solvers will + provide the ray but not the feasible solution when returning an unbounded + status. + * When the termination reason is that a limit was reached or that the result + is imprecise, a solution may or may not be present. Further, for some + solvers (generally, convex optimization solvers, not MIP solvers), the + primal or dual solution may not be feasible. + + Solver specific output is also returned for some solvers (and only information + for the solver used will be populated). + + Attributes: + termination: The reason the solver stopped. + solve_stats: Statistics on the solve process, e.g. running time, iterations. + solutions: Lexicographically by primal feasibility status, dual feasibility + status, (basic dual feasibility for simplex solvers), primal objective + value and dual objective value. + primal_rays: Directions of unbounded primal improvement, or equivalently, + dual infeasibility certificates. Typically provided for terminal reasons + UNBOUNDED and DUAL_INFEASIBLE. + dual_rays: Directions of unbounded dual improvement, or equivalently, primal + infeasibility certificates. Typically provided for termination reason + INFEASIBLE. + gscip_specific_output: statistics returned by the gSCIP solver, if used. + osqp_specific_output: statistics returned by the OSQP solver, if used. + pdlp_specific_output: statistics returned by the PDLP solver, if used. + """ + + termination: Termination = dataclasses.field(default_factory=Termination) + solve_stats: SolveStats = dataclasses.field(default_factory=SolveStats) + solutions: List[solution.Solution] = dataclasses.field(default_factory=list) + primal_rays: List[solution.PrimalRay] = dataclasses.field(default_factory=list) + dual_rays: List[solution.DualRay] = dataclasses.field(default_factory=list) + # At most one of the below will be set + gscip_specific_output: Optional[gscip_pb2.GScipOutput] = None + osqp_specific_output: Optional[osqp_pb2.OsqpOutput] = None + pdlp_specific_output: Optional[result_pb2.SolveResultProto.PdlpOutput] = None + + def solve_time(self) -> datetime.timedelta: + """Shortcut for SolveResult.solve_stats.solve_time.""" + return self.solve_stats.solve_time + + def primal_bound(self) -> float: + """Returns a primal bound on the optimal objective value as described in ObjectiveBounds. + + Will return a valid (possibly infinite) bound even if no primal feasible + solutions are available. + """ + return self.termination.objective_bounds.primal_bound + + def dual_bound(self) -> float: + """Returns a dual bound on the optimal objective value as described in ObjectiveBounds. + + Will return a valid (possibly infinite) bound even if no dual feasible + solutions are available. + """ + return self.termination.objective_bounds.dual_bound + + def has_primal_feasible_solution(self) -> bool: + """Indicates if at least one primal feasible solution is available. + + When termination.reason is TerminationReason.OPTIMAL or + TerminationReason.FEASIBLE, this is guaranteed to be true and need not be + checked. + + Returns: + True if there is at least one primal feasible solution is available, + False, otherwise. + """ + if not self.solutions: + return False + return ( + self.solutions[0].primal_solution is not None + and self.solutions[0].primal_solution.feasibility_status + == solution.SolutionStatus.FEASIBLE + ) + + def objective_value(self) -> float: + """Returns the objective value of the best primal feasible solution. + + An error will be raised if there are no primal feasible solutions. + primal_bound() above is guaranteed to be at least as good (larger or equal + for max problems and smaller or equal for min problems) as objective_value() + and will never raise an error, so it may be preferable in some cases. Note + that primal_bound() could be better than objective_value() even for optimal + terminations, but on such optimal termination, both should satisfy the + optimality tolerances. + + Returns: + The objective value of the best primal feasible solution. + + Raises: + ValueError: There are no primal feasible solutions. + """ + if not self.has_primal_feasible_solution(): + raise ValueError("No primal feasible solution available.") + assert self.solutions[0].primal_solution is not None + return self.solutions[0].primal_solution.objective_value + + def best_objective_bound(self) -> float: + """Returns a bound on the best possible objective value. + + best_objective_bound() is always equal to dual_bound(), so they can be + used interchangeably. + """ + return self.termination.objective_bounds.dual_bound + + @overload + def variable_values(self, variables: None = ...) -> Dict[model.Variable, float]: + ... + + @overload + def variable_values(self, variables: model.Variable) -> float: + ... + + @overload + def variable_values(self, variables: Iterable[model.Variable]) -> List[float]: + ... + + def variable_values(self, variables=None): + """The variable values from the best primal feasible solution. + + An error will be raised if there are no primal feasible solutions. + + Args: + variables: an optional Variable or iterator of Variables indicating what + variable values to return. If not provided, variable_values returns a + dictionary with all the variable values for all variables. + + Returns: + The variable values from the best primal feasible solution. + + Raises: + ValueError: There are no primal feasible solutions. + TypeError: Argument is not None, a Variable or an iterable of Variables. + KeyError: Variable values requested for an invalid variable (e.g. is not a + Variable or is a variable for another model). + """ + if not self.has_primal_feasible_solution(): + raise ValueError("No primal feasible solution available.") + assert self.solutions[0].primal_solution is not None + if variables is None: + return self.solutions[0].primal_solution.variable_values + if isinstance(variables, model.Variable): + return self.solutions[0].primal_solution.variable_values[variables] + if isinstance(variables, Iterable): + return [ + self.solutions[0].primal_solution.variable_values[v] for v in variables + ] + raise TypeError( + "unsupported type in argument for " + f"variable_values: {type(variables).__name__!r}" + ) + + def bounded(self) -> bool: + """Returns true only if the problem has been shown to be feasible and bounded.""" + return ( + self.termination.problem_status.primal_status == FeasibilityStatus.FEASIBLE + and self.termination.problem_status.dual_status + == FeasibilityStatus.FEASIBLE + ) + + def has_ray(self) -> bool: + """Indicates if at least one primal ray is available. + + This is NOT guaranteed to be true when termination.reason is + TerminationReason.kUnbounded or TerminationReason.kInfeasibleOrUnbounded. + + Returns: + True if at least one primal ray is available. + """ + return bool(self.primal_rays) + + @overload + def ray_variable_values(self, variables: None = ...) -> Dict[model.Variable, float]: + ... + + @overload + def ray_variable_values(self, variables: model.Variable) -> float: + ... + + @overload + def ray_variable_values(self, variables: Iterable[model.Variable]) -> List[float]: + ... + + def ray_variable_values(self, variables=None): + """The variable values from the first primal ray. + + An error will be raised if there are no primal rays. + + Args: + variables: an optional Variable or iterator of Variables indicating what + variable values to return. If not provided, variable_values() returns a + dictionary with the variable values for all variables. + + Returns: + The variable values from the first primal ray. + + Raises: + ValueError: There are no primal rays. + TypeError: Argument is not None, a Variable or an iterable of Variables. + KeyError: Variable values requested for an invalid variable (e.g. is not a + Variable or is a variable for another model). + """ + if not self.has_ray(): + raise ValueError("No primal ray available.") + if variables is None: + return self.primal_rays[0].variable_values + if isinstance(variables, model.Variable): + return self.primal_rays[0].variable_values[variables] + if isinstance(variables, Iterable): + return [self.primal_rays[0].variable_values[v] for v in variables] + raise TypeError( + "unsupported type in argument for " + f"ray_variable_values: {type(variables).__name__!r}" + ) + + def has_dual_feasible_solution(self) -> bool: + """Indicates if the best solution has an associated dual feasible solution. + + This is NOT guaranteed to be true when termination.reason is + TerminationReason.Optimal. It also may be true even when the best solution + does not have an associated primal feasible solution. + + Returns: + True if the best solution has an associated dual feasible solution. + """ + if not self.solutions: + return False + return ( + self.solutions[0].dual_solution is not None + and self.solutions[0].dual_solution.feasibility_status + == solution.SolutionStatus.FEASIBLE + ) + + @overload + def dual_values( + self, linear_constraints: None = ... + ) -> Dict[model.LinearConstraint, float]: + ... + + @overload + def dual_values(self, linear_constraints: model.LinearConstraint) -> float: + ... + + @overload + def dual_values( + self, linear_constraints: Iterable[model.LinearConstraint] + ) -> List[float]: + ... + + def dual_values(self, linear_constraints=None): + """The dual values associated to the best solution. + + If there is at least one primal feasible solution, this corresponds to the + dual values associated to the best primal feasible solution. An error will + be raised if the best solution does not have an associated dual feasible + solution. + + Args: + linear_constraints: an optional LinearConstraint or iterator of + LinearConstraint indicating what dual values to return. If not provided, + dual_values() returns a dictionary with the dual values for all linear + constraints. + + Returns: + The dual values associated to the best solution. + + Raises: + ValueError: The best solution does not have an associated dual feasible + solution. + TypeError: Argument is not None, a LinearConstraint or an iterable of + LinearConstraint. + KeyError: LinearConstraint values requested for an invalid + linear constraint (e.g. is not a LinearConstraint or is a linear + constraint for another model). + """ + if not self.has_dual_feasible_solution(): + raise ValueError(_NO_DUAL_SOLUTION_ERROR) + assert self.solutions[0].dual_solution is not None + if linear_constraints is None: + return self.solutions[0].dual_solution.dual_values + if isinstance(linear_constraints, model.LinearConstraint): + return self.solutions[0].dual_solution.dual_values[linear_constraints] + if isinstance(linear_constraints, Iterable): + return [ + self.solutions[0].dual_solution.dual_values[c] + for c in linear_constraints + ] + raise TypeError( + "unsupported type in argument for " + f"dual_values: {type(linear_constraints).__name__!r}" + ) + + @overload + def reduced_costs(self, variables: None = ...) -> Dict[model.Variable, float]: + ... + + @overload + def reduced_costs(self, variables: model.Variable) -> float: + ... + + @overload + def reduced_costs(self, variables: Iterable[model.Variable]) -> List[float]: + ... + + def reduced_costs(self, variables=None): + """The reduced costs associated to the best solution. + + If there is at least one primal feasible solution, this corresponds to the + reduced costs associated to the best primal feasible solution. An error will + be raised if the best solution does not have an associated dual feasible + solution. + + Args: + variables: an optional Variable or iterator of Variables indicating what + reduced costs to return. If not provided, reduced_costs() returns a + dictionary with the reduced costs for all variables. + + Returns: + The reduced costs associated to the best solution. + + Raises: + ValueError: The best solution does not have an associated dual feasible + solution. + TypeError: Argument is not None, a Variable or an iterable of Variables. + KeyError: Variable values requested for an invalid variable (e.g. is not a + Variable or is a variable for another model). + """ + if not self.has_dual_feasible_solution(): + raise ValueError(_NO_DUAL_SOLUTION_ERROR) + assert self.solutions[0].dual_solution is not None + if variables is None: + return self.solutions[0].dual_solution.reduced_costs + if isinstance(variables, model.Variable): + return self.solutions[0].dual_solution.reduced_costs[variables] + if isinstance(variables, Iterable): + return [self.solutions[0].dual_solution.reduced_costs[v] for v in variables] + raise TypeError( + "unsupported type in argument for " + f"reduced_costs: {type(variables).__name__!r}" + ) + + def has_dual_ray(self) -> bool: + """Indicates if at least one dual ray is available. + + This is NOT guaranteed to be true when termination.reason is + TerminationReason.Infeasible. + + Returns: + True if at least one dual ray is available. + """ + return bool(self.dual_rays) + + @overload + def ray_dual_values( + self, linear_constraints: None = ... + ) -> Dict[model.LinearConstraint, float]: + ... + + @overload + def ray_dual_values(self, linear_constraints: model.LinearConstraint) -> float: + ... + + @overload + def ray_dual_values( + self, linear_constraints: Iterable[model.LinearConstraint] + ) -> List[float]: + ... + + def ray_dual_values(self, linear_constraints=None): + """The dual values from the first dual ray. + + An error will be raised if there are no dual rays. + + Args: + linear_constraints: an optional LinearConstraint or iterator of + LinearConstraint indicating what dual values to return. If not provided, + ray_dual_values() returns a dictionary with the dual values for all + linear constraints. + + Returns: + The dual values from the first dual ray. + + Raises: + ValueError: There are no dual rays. + TypeError: Argument is not None, a LinearConstraint or an iterable of + LinearConstraint. + KeyError: LinearConstraint values requested for an invalid + linear constraint (e.g. is not a LinearConstraint or is a linear + constraint for another model). + """ + if not self.has_dual_ray(): + raise ValueError("No dual ray available.") + if linear_constraints is None: + return self.dual_rays[0].dual_values + if isinstance(linear_constraints, model.LinearConstraint): + return self.dual_rays[0].dual_values[linear_constraints] + if isinstance(linear_constraints, Iterable): + return [self.dual_rays[0].dual_values[v] for v in linear_constraints] + raise TypeError( + "unsupported type in argument for " + f"ray_dual_values: {type(linear_constraints).__name__!r}" + ) + + @overload + def ray_reduced_costs(self, variables: None = ...) -> Dict[model.Variable, float]: + ... + + @overload + def ray_reduced_costs(self, variables: model.Variable) -> float: + ... + + @overload + def ray_reduced_costs(self, variables: Iterable[model.Variable]) -> List[float]: + ... + + def ray_reduced_costs(self, variables=None): + """The reduced costs from the first dual ray. + + An error will be raised if there are no dual rays. + + Args: + variables: an optional Variable or iterator of Variables indicating what + reduced costs to return. If not provided, ray_reduced_costs() returns a + dictionary with the reduced costs for all variables. + + Returns: + The reduced costs from the first dual ray. + + Raises: + ValueError: There are no dual rays. + TypeError: Argument is not None, a Variable or an iterable of Variables. + KeyError: Variable values requested for an invalid variable (e.g. is not a + Variable or is a variable for another model). + """ + if not self.has_dual_ray(): + raise ValueError("No dual ray available.") + if variables is None: + return self.dual_rays[0].reduced_costs + if isinstance(variables, model.Variable): + return self.dual_rays[0].reduced_costs[variables] + if isinstance(variables, Iterable): + return [self.dual_rays[0].reduced_costs[v] for v in variables] + raise TypeError( + "unsupported type in argument for " + f"ray_reduced_costs: {type(variables).__name__!r}" + ) + + def has_basis(self) -> bool: + """Indicates if the best solution has an associated basis. + + This is NOT guaranteed to be true when termination.reason is + TerminationReason.Optimal. It also may be true even when the best solution + does not have an associated primal feasible solution. + + Returns: + True if the best solution has an associated basis. + """ + if not self.solutions: + return False + return self.solutions[0].basis is not None + + @overload + def constraint_status( + self, linear_constraints: None = ... + ) -> Dict[model.LinearConstraint, solution.BasisStatus]: + ... + + @overload + def constraint_status( + self, linear_constraints: model.LinearConstraint + ) -> solution.BasisStatus: + ... + + @overload + def constraint_status( + self, linear_constraints: Iterable[model.LinearConstraint] + ) -> List[solution.BasisStatus]: + ... + + def constraint_status(self, linear_constraints=None): + """The constraint basis status associated to the best solution. + + If there is at least one primal feasible solution, this corresponds to the + basis associated to the best primal feasible solution. An error will + be raised if the best solution does not have an associated basis. + + + Args: + linear_constraints: an optional LinearConstraint or iterator of + LinearConstraint indicating what constraint statuses to return. If not + provided, returns a dictionary with the constraint statuses for all + linear constraints. + + Returns: + The constraint basis status associated to the best solution. + + Raises: + ValueError: The best solution does not have an associated basis. + TypeError: Argument is not None, a LinearConstraint or an iterable of + LinearConstraint. + KeyError: LinearConstraint values requested for an invalid + linear constraint (e.g. is not a LinearConstraint or is a linear + constraint for another model). + """ + if not self.has_basis(): + raise ValueError(_NO_BASIS_ERROR) + assert self.solutions[0].basis is not None + if linear_constraints is None: + return self.solutions[0].basis.constraint_status + if isinstance(linear_constraints, model.LinearConstraint): + return self.solutions[0].basis.constraint_status[linear_constraints] + if isinstance(linear_constraints, Iterable): + return [ + self.solutions[0].basis.constraint_status[c] for c in linear_constraints + ] + raise TypeError( + "unsupported type in argument for " + f"constraint_status: {type(linear_constraints).__name__!r}" + ) + + @overload + def variable_status( + self, variables: None = ... + ) -> Dict[model.Variable, solution.BasisStatus]: + ... + + @overload + def variable_status(self, variables: model.Variable) -> solution.BasisStatus: + ... + + @overload + def variable_status( + self, variables: Iterable[model.Variable] + ) -> List[solution.BasisStatus]: + ... + + def variable_status(self, variables=None): + """The variable basis status associated to the best solution. + + If there is at least one primal feasible solution, this corresponds to the + basis associated to the best primal feasible solution. An error will + be raised if the best solution does not have an associated basis. + + Args: + variables: an optional Variable or iterator of Variables indicating what + reduced costs to return. If not provided, variable_status() returns a + dictionary with the reduced costs for all variables. + + Returns: + The variable basis status associated to the best solution. + + Raises: + ValueError: The best solution does not have an associated basis. + TypeError: Argument is not None, a Variable or an iterable of Variables. + KeyError: Variable values requested for an invalid variable (e.g. is not a + Variable or is a variable for another model). + """ + if not self.has_basis(): + raise ValueError(_NO_BASIS_ERROR) + assert self.solutions[0].basis is not None + if variables is None: + return self.solutions[0].basis.variable_status + if isinstance(variables, model.Variable): + return self.solutions[0].basis.variable_status[variables] + if isinstance(variables, Iterable): + return [self.solutions[0].basis.variable_status[v] for v in variables] + raise TypeError( + "unsupported type in argument for " + f"variable_status: {type(variables).__name__!r}" + ) + + +def _get_problem_status( + result_proto: result_pb2.SolveResultProto, +) -> result_pb2.ProblemStatusProto: + if result_proto.termination.HasField("problem_status"): + return result_proto.termination.problem_status + return result_proto.solve_stats.problem_status + + +def _get_objective_bounds( + result_proto: result_pb2.SolveResultProto, +) -> result_pb2.ObjectiveBoundsProto: + if result_proto.termination.HasField("objective_bounds"): + return result_proto.termination.objective_bounds + return result_pb2.ObjectiveBoundsProto( + primal_bound=result_proto.solve_stats.best_primal_bound, + dual_bound=result_proto.solve_stats.best_dual_bound, + ) + + +def _upgrade_termination( + result_proto: result_pb2.SolveResultProto, +) -> result_pb2.TerminationProto: + return result_pb2.TerminationProto( + reason=result_proto.termination.reason, + limit=result_proto.termination.limit, + detail=result_proto.termination.detail, + problem_status=_get_problem_status(result_proto), + objective_bounds=_get_objective_bounds(result_proto), + ) + + +def parse_solve_result( + proto: result_pb2.SolveResultProto, mod: model.Model +) -> SolveResult: + """Returns a SolveResult equivalent to the input proto.""" + result = SolveResult() + # TODO(b/290091715): change to parse_termination(proto.termination) + # once solve_stats proto no longer has best_primal/dual_bound/problem_status + # and problem_status/objective_bounds are guaranteed to be present in + # termination proto. + result.termination = parse_termination(_upgrade_termination(proto)) + result.solve_stats = parse_solve_stats(proto.solve_stats) + for solution_proto in proto.solutions: + result.solutions.append(solution.parse_solution(solution_proto, mod)) + for primal_ray_proto in proto.primal_rays: + result.primal_rays.append(solution.parse_primal_ray(primal_ray_proto, mod)) + for dual_ray_proto in proto.dual_rays: + result.dual_rays.append(solution.parse_dual_ray(dual_ray_proto, mod)) + if proto.HasField("gscip_output"): + result.gscip_specific_output = proto.gscip_output + elif proto.HasField("osqp_output"): + result.osqp_specific_output = proto.osqp_output + elif proto.HasField("pdlp_output"): + result.pdlp_specific_output = proto.pdlp_output + return result diff --git a/ortools/math_opt/python/result_test.py b/ortools/math_opt/python/result_test.py new file mode 100644 index 0000000000..c588dedcff --- /dev/null +++ b/ortools/math_opt/python/result_test.py @@ -0,0 +1,1047 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import math + +import unittest +from ortools.math_opt import result_pb2 +from ortools.math_opt import solution_pb2 +from ortools.math_opt import sparse_containers_pb2 +from ortools.math_opt.python import model +from ortools.math_opt.python import result +from ortools.math_opt.python import solution +from ortools.math_opt.python.testing import compare_proto + + +class ParseTerminationReason(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_termination_unspecified(self) -> None: + termination_proto = result_pb2.TerminationProto( + reason=result_pb2.TERMINATION_REASON_UNSPECIFIED + ) + with self.assertRaisesRegex(ValueError, "Termination.*UNSPECIFIED"): + result.parse_termination(termination_proto) + + def test_termination_limit_but_not_limit_reason(self) -> None: + termination_proto = result_pb2.TerminationProto( + reason=result_pb2.TERMINATION_REASON_OPTIMAL, + limit=result_pb2.LIMIT_OTHER, + ) + with self.assertRaisesRegex( + ValueError, "Termination limit.*FEASIBLE or NO_SOLUTION_FOUND" + ): + result.parse_termination(termination_proto) + + def test_termination_limit_reason_but_no_limit(self) -> None: + termination_proto = result_pb2.TerminationProto( + reason=result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND, + limit=result_pb2.LIMIT_UNSPECIFIED, + ) + with self.assertRaisesRegex( + ValueError, "Termination limit.*FEASIBLE or NO_SOLUTION_FOUND" + ): + result.parse_termination(termination_proto) + + def test_termination_ok(self) -> None: + termination_proto = result_pb2.TerminationProto( + reason=result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND, + limit=result_pb2.LIMIT_OTHER, + detail="detail", + problem_status=result_pb2.ProblemStatusProto( + primal_status=result_pb2.FEASIBILITY_STATUS_FEASIBLE, + dual_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE, + primal_or_dual_infeasible=False, + ), + objective_bounds=result_pb2.ObjectiveBoundsProto( + primal_bound=10, dual_bound=20 + ), + ) + termination = result.parse_termination(termination_proto) + self.assertEqual(termination.reason, result.TerminationReason.NO_SOLUTION_FOUND) + self.assertEqual(termination.limit, result.Limit.OTHER) + self.assertEqual(termination.detail, "detail") + self.assertEqual( + termination.problem_status, + result.ProblemStatus( + primal_status=result.FeasibilityStatus.FEASIBLE, + dual_status=result.FeasibilityStatus.INFEASIBLE, + primal_or_dual_infeasible=False, + ), + ) + self.assertEqual( + termination.objective_bounds, + result.ObjectiveBounds(primal_bound=10, dual_bound=20), + ) + + +class ParseProblemStatus(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_problem_status_round_trip(self) -> None: + problem_status = result.ProblemStatus( + primal_status=result.FeasibilityStatus.FEASIBLE, + dual_status=result.FeasibilityStatus.INFEASIBLE, + primal_or_dual_infeasible=False, + ) + problem_status_proto = problem_status.to_proto() + expected_proto = result_pb2.ProblemStatusProto( + primal_status=result_pb2.FEASIBILITY_STATUS_FEASIBLE, + dual_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE, + primal_or_dual_infeasible=False, + ) + self.assert_protos_equiv(expected_proto, problem_status_proto) + round_trip_status = result.parse_problem_status(problem_status_proto) + self.assertEqual(problem_status, round_trip_status) + + def test_problem_status_unspecified_primal_status(self) -> None: + proto = result_pb2.ProblemStatusProto( + primal_status=result_pb2.FEASIBILITY_STATUS_UNSPECIFIED, + dual_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE, + primal_or_dual_infeasible=False, + ) + with self.assertRaisesRegex( + ValueError, "Primal feasibility status.*UNSPECIFIED" + ): + result.parse_problem_status(proto) + + def test_problem_status_unspecified_dual_status(self) -> None: + proto = result_pb2.ProblemStatusProto( + primal_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE, + dual_status=result_pb2.FEASIBILITY_STATUS_UNSPECIFIED, + primal_or_dual_infeasible=False, + ) + with self.assertRaisesRegex(ValueError, "Dual feasibility status.*UNSPECIFIED"): + result.parse_problem_status(proto) + + +class ParseObjectiveBounds(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_objective_bounds_round_trip(self) -> None: + objective_bounds = result.ObjectiveBounds(primal_bound=10, dual_bound=20) + objective_bounds_proto = objective_bounds.to_proto() + expected_proto = result_pb2.ObjectiveBoundsProto(primal_bound=10, dual_bound=20) + self.assert_protos_equiv(expected_proto, objective_bounds_proto) + round_trip_objective_bounds = result.parse_objective_bounds( + objective_bounds_proto + ) + self.assertEqual(objective_bounds, round_trip_objective_bounds) + + +class ParseSolveStats(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_problem_status_round_trip(self) -> None: + solve_stats = result.SolveStats( + solve_time=datetime.timedelta(seconds=10), + simplex_iterations=10, + barrier_iterations=20, + first_order_iterations=30, + node_count=40, + ) + solve_stats_proto = solve_stats.to_proto() + expected_proto = result_pb2.SolveStatsProto() + expected_proto.solve_time.seconds = 10 + expected_proto.simplex_iterations = 10 + expected_proto.barrier_iterations = 20 + expected_proto.first_order_iterations = 30 + expected_proto.node_count = 40 + self.assert_protos_equiv(expected_proto, solve_stats_proto) + round_trip_solve_stats = result.parse_solve_stats(solve_stats_proto) + self.assertEqual(solve_stats, round_trip_solve_stats) + + +class SolveResultAuxiliaryFunctionsTest(unittest.TestCase): + def test_solve_time(self) -> None: + res = result.SolveResult( + solve_stats=result.SolveStats(solve_time=datetime.timedelta(seconds=10)) + ) + self.assertEqual(res.solve_time(), datetime.timedelta(seconds=10)) + + def test_best_objective_bound(self) -> None: + res = result.SolveResult( + termination=result.Termination( + objective_bounds=result.ObjectiveBounds(dual_bound=10.0) + ) + ) + self.assertEqual(res.best_objective_bound(), 10.0) + + def test_primal_solution_has_feasible(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + other_mod = model.Model(name="other_test_model") + other_x = other_mod.add_binary_variable(name="other_x") + res = result.SolveResult() + res.solutions.append( + solution.Solution( + primal_solution=solution.PrimalSolution( + variable_values={x: 2.0, y: 1.0}, + objective_value=3.0, + feasibility_status=solution.SolutionStatus.FEASIBLE, + ) + ) + ) + self.assertTrue(res.has_primal_feasible_solution()) + self.assertEqual(res.objective_value(), 3.0) + self.assertDictEqual(res.variable_values(), {x: 2.0, y: 1.0}) + self.assertEqual(res.variable_values()[x], 2.0) + self.assertEqual(res.variable_values([]), []) + self.assertEqual(res.variable_values([y, x]), [1.0, 2.0]) + self.assertEqual(res.variable_values(y), 1.0) + with self.assertRaisesRegex(KeyError, ".*other_x"): + res.variable_values(other_x) + with self.assertRaisesRegex(KeyError, ".*string"): + res.variable_values([y, "string"]) + with self.assertRaisesRegex(TypeError, ".*int"): + res.variable_values(20) # pytype: disable=wrong-arg-types + + def test_primal_solution_no_feasible(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + res = result.SolveResult() + res.solutions.append( + solution.Solution( + primal_solution=solution.PrimalSolution( + variable_values={ + x: 2.0, + }, + objective_value=3.0, + feasibility_status=solution.SolutionStatus.UNDETERMINED, + ) + ) + ) + self.assertFalse(res.has_primal_feasible_solution()) + with self.assertRaisesRegex(ValueError, "No primal feasible.*"): + res.objective_value() + with self.assertRaisesRegex(ValueError, "No primal feasible.*"): + res.variable_values() + + def test_primal_solution_no_primal(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + res = result.SolveResult() + res.solutions.append( + solution.Solution( + dual_solution=solution.DualSolution( + dual_values={c: 3.0}, + reduced_costs={x: 1.0}, + objective_value=2.0, + feasibility_status=solution.SolutionStatus.FEASIBLE, + ) + ) + ) + self.assertFalse(res.has_primal_feasible_solution()) + with self.assertRaisesRegex(ValueError, "No primal feasible.*"): + res.objective_value() + with self.assertRaisesRegex(ValueError, "No primal feasible.*"): + res.variable_values() + + def test_primal_solution_no_solution(self) -> None: + res = result.SolveResult() + self.assertFalse(res.has_primal_feasible_solution()) + with self.assertRaisesRegex(ValueError, "No primal feasible.*"): + res.objective_value() + with self.assertRaisesRegex(ValueError, "No primal feasible.*"): + res.variable_values() + + def test_dual_solution_has_feasible(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + other_mod = model.Model(name="other_test_model") + other_x = other_mod.add_binary_variable(name="other_x") + other_c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="other_c") + res = result.SolveResult() + res.solutions.append( + solution.Solution( + dual_solution=solution.DualSolution( + dual_values={c: 3.0, d: 4.0}, + reduced_costs={x: 1.0, y: -2.0}, + objective_value=2.0, + feasibility_status=solution.SolutionStatus.FEASIBLE, + ) + ) + ) + self.assertTrue(res.has_dual_feasible_solution()) + # Reduced costs. + self.assertDictEqual(res.reduced_costs(), {x: 1.0, y: -2.0}) + self.assertEqual(res.reduced_costs()[x], 1.0) + self.assertEqual(res.reduced_costs([]), []) + self.assertEqual(res.reduced_costs([y, x]), [-2.0, 1.0]) + self.assertEqual(res.reduced_costs(y), -2.0) + with self.assertRaisesRegex(KeyError, ".*other_x"): + res.reduced_costs(other_x) + with self.assertRaisesRegex(KeyError, ".*string"): + res.reduced_costs([y, "string"]) + with self.assertRaisesRegex(TypeError, ".*int"): + res.reduced_costs(20) # pytype: disable=wrong-arg-types + # Dual values. + self.assertDictEqual(res.dual_values(), {c: 3.0, d: 4.0}) + self.assertEqual(res.dual_values()[c], 3.0) + self.assertEqual(res.dual_values([]), []) + self.assertEqual(res.dual_values([d, c]), [4.0, 3.0]) + self.assertEqual(res.dual_values(c), 3.0) + with self.assertRaisesRegex(KeyError, ".*other_c"): + res.dual_values(other_c) + with self.assertRaisesRegex(KeyError, ".*string"): + res.dual_values([d, "string"]) + with self.assertRaisesRegex(TypeError, ".*int"): + res.dual_values(20) # pytype: disable=wrong-arg-types + + def test_dual_solution_no_feasible(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + res = result.SolveResult() + res.solutions.append( + solution.Solution( + dual_solution=solution.DualSolution( + dual_values={c: 3.0}, + reduced_costs={ + x: 1.0, + }, + objective_value=2.0, + feasibility_status=solution.SolutionStatus.UNDETERMINED, + ) + ) + ) + self.assertFalse(res.has_dual_feasible_solution()) + with self.assertRaisesRegex(ValueError, "Best solution.*dual feasible.*"): + res.reduced_costs() + with self.assertRaisesRegex(ValueError, "Best solution.*dual feasible.*"): + res.dual_values() + + def test_dual_solution_no_dual_in_best_solution(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + res = result.SolveResult() + res.solutions.append( + solution.Solution( + primal_solution=solution.PrimalSolution( + variable_values={ + x: 2.0, + }, + objective_value=3.0, + feasibility_status=solution.SolutionStatus.FEASIBLE, + ) + ) + ) + res.solutions.append( + solution.Solution( + dual_solution=solution.DualSolution( + dual_values={c: 3.0}, + reduced_costs={ + x: 1.0, + }, + objective_value=2.0, + feasibility_status=solution.SolutionStatus.FEASIBLE, + ) + ) + ) + self.assertFalse(res.has_dual_feasible_solution()) + with self.assertRaisesRegex(ValueError, "Best solution.*dual feasible.*"): + res.reduced_costs() + with self.assertRaisesRegex(ValueError, "Best solution.*dual feasible.*"): + res.dual_values() + + def test_dual_solution_no_solution(self) -> None: + res = result.SolveResult() + self.assertFalse(res.has_dual_feasible_solution()) + with self.assertRaisesRegex(ValueError, "Best solution.*dual feasible.*"): + res.reduced_costs() + with self.assertRaisesRegex(ValueError, "Best solution.*dual feasible.*"): + res.dual_values() + + def test_primal_ray_has_ray(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + other_mod = model.Model(name="other_test_model") + other_x = other_mod.add_binary_variable(name="other_x") + res = result.SolveResult() + res.primal_rays.append(solution.PrimalRay(variable_values={x: 2.0, y: 1.0})) + self.assertTrue(res.has_ray()) + self.assertDictEqual(res.ray_variable_values(), {x: 2.0, y: 1.0}) + self.assertEqual(res.ray_variable_values()[x], 2.0) + self.assertEqual(res.ray_variable_values([]), []) + self.assertEqual(res.ray_variable_values([y, x]), [1.0, 2.0]) + self.assertEqual(res.ray_variable_values(y), 1.0) + with self.assertRaisesRegex(KeyError, ".*other_x"): + res.ray_variable_values(other_x) + with self.assertRaisesRegex(KeyError, ".*string"): + res.ray_variable_values([y, "string"]) + with self.assertRaisesRegex(TypeError, ".*int"): + res.ray_variable_values(20) # pytype: disable=wrong-arg-types + + def test_primal_ray_no_ray(self) -> None: + res = result.SolveResult() + self.assertFalse(res.has_ray()) + with self.assertRaisesRegex(ValueError, ".*primal ray.*"): + res.ray_variable_values() + + def test_dual_ray_has_ray(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + other_mod = model.Model(name="other_test_model") + other_x = other_mod.add_binary_variable(name="other_x") + other_c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="other_c") + res = result.SolveResult() + res.dual_rays.append( + solution.DualRay( + dual_values={c: 3.0, d: 4.0}, reduced_costs={x: 1.0, y: -2.0} + ) + ) + self.assertTrue(res.has_dual_ray()) + self.assertDictEqual(res.ray_reduced_costs(), {x: 1.0, y: -2.0}) + # Reduced costs. + self.assertEqual(res.ray_reduced_costs()[x], 1.0) + self.assertEqual(res.ray_reduced_costs([]), []) + self.assertEqual(res.ray_reduced_costs([y, x]), [-2.0, 1.0]) + self.assertEqual(res.ray_reduced_costs(y), -2.0) + with self.assertRaisesRegex(KeyError, ".*other_x"): + res.ray_reduced_costs(other_x) + with self.assertRaisesRegex(KeyError, ".*string"): + res.ray_reduced_costs([y, "string"]) + with self.assertRaisesRegex(TypeError, ".*int"): + res.ray_reduced_costs(20) # pytype: disable=wrong-arg-types + # Dual values. + self.assertDictEqual(res.ray_dual_values(), {c: 3.0, d: 4.0}) + self.assertEqual(res.ray_dual_values()[c], 3.0) + self.assertEqual(res.ray_dual_values([]), []) + self.assertEqual(res.ray_dual_values([d, c]), [4.0, 3.0]) + self.assertEqual(res.ray_dual_values(c), 3.0) + with self.assertRaisesRegex(KeyError, ".*other_c"): + res.ray_dual_values(other_c) + with self.assertRaisesRegex(KeyError, ".*string"): + res.ray_dual_values([d, "string"]) + with self.assertRaisesRegex(TypeError, ".*int"): + res.ray_dual_values(20) # pytype: disable=wrong-arg-types + + def test_dual_ray_no_ray(self) -> None: + res = result.SolveResult() + self.assertFalse(res.has_dual_ray()) + with self.assertRaisesRegex(ValueError, ".*dual ray.*"): + res.ray_dual_values() + with self.assertRaisesRegex(ValueError, ".*dual ray.*"): + res.ray_reduced_costs() + + def test_basis_has_basis(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + other_mod = model.Model(name="other_test_model") + other_x = other_mod.add_binary_variable(name="other_x") + other_c = other_mod.add_linear_constraint(name="other_c") + res = result.SolveResult() + res.solutions.append( + solution.Solution( + basis=solution.Basis( + variable_status={ + x: solution.BasisStatus.AT_LOWER_BOUND, + y: solution.BasisStatus.AT_UPPER_BOUND, + }, + constraint_status={ + c: solution.BasisStatus.BASIC, + d: solution.BasisStatus.FIXED_VALUE, + }, + ) + ) + ) + self.assertTrue(res.has_basis()) + # Variable status + self.assertDictEqual( + res.variable_status(), + { + x: solution.BasisStatus.AT_LOWER_BOUND, + y: solution.BasisStatus.AT_UPPER_BOUND, + }, + ) + self.assertEqual(res.variable_status()[x], solution.BasisStatus.AT_LOWER_BOUND) + self.assertEqual(res.variable_status([]), []) + self.assertEqual( + res.variable_status([y, x]), + [ + solution.BasisStatus.AT_UPPER_BOUND, + solution.BasisStatus.AT_LOWER_BOUND, + ], + ) + self.assertEqual(res.variable_status(y), solution.BasisStatus.AT_UPPER_BOUND) + with self.assertRaisesRegex(KeyError, ".*other_x"): + res.variable_status(other_x) + with self.assertRaisesRegex(KeyError, ".*string"): + res.variable_status([y, "string"]) + with self.assertRaisesRegex(TypeError, ".*int"): + res.variable_status(20) # pytype: disable=wrong-arg-types + # Constraint status + self.assertDictEqual( + res.constraint_status(), + {c: solution.BasisStatus.BASIC, d: solution.BasisStatus.FIXED_VALUE}, + ) + self.assertEqual(res.constraint_status()[c], solution.BasisStatus.BASIC) + self.assertEqual(res.constraint_status([]), []) + self.assertEqual( + res.constraint_status([d, c]), + [solution.BasisStatus.FIXED_VALUE, solution.BasisStatus.BASIC], + ) + self.assertEqual(res.constraint_status(c), solution.BasisStatus.BASIC) + with self.assertRaisesRegex(KeyError, ".*other_c"): + res.constraint_status(other_c) + with self.assertRaisesRegex(KeyError, ".*string"): + res.constraint_status([d, "string"]) + with self.assertRaisesRegex(TypeError, ".*int"): + res.constraint_status(20) # pytype: disable=wrong-arg-types + + def test_basis_no_basis_in_best_solution(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + res = result.SolveResult() + res.solutions.append( + solution.Solution( + primal_solution=solution.PrimalSolution( + variable_values={x: 2.0, y: 1.0}, + objective_value=3.0, + feasibility_status=solution.SolutionStatus.FEASIBLE, + ) + ) + ) + res.solutions.append( + solution.Solution( + basis=solution.Basis( + variable_status={ + x: solution.BasisStatus.AT_LOWER_BOUND, + y: solution.BasisStatus.AT_UPPER_BOUND, + }, + constraint_status={c: solution.BasisStatus.BASIC}, + ) + ) + ) + self.assertFalse(res.has_basis()) + with self.assertRaisesRegex(ValueError, "Best solution.*basis.*"): + res.variable_status() + with self.assertRaisesRegex(ValueError, "Best solution.*basis.*"): + res.constraint_status() + + def test_basis_no_solution(self) -> None: + res = result.SolveResult() + self.assertFalse(res.has_basis()) + with self.assertRaisesRegex(ValueError, "Best solution.*basis.*"): + res.variable_status() + with self.assertRaisesRegex(ValueError, "Best solution.*basis.*"): + res.constraint_status() + + def test_bounded(self) -> None: + res = result.SolveResult( + termination=result.Termination( + reason=result.TerminationReason.NO_SOLUTION_FOUND, + problem_status=result.ProblemStatus( + primal_status=result.FeasibilityStatus.FEASIBLE, + dual_status=result.FeasibilityStatus.FEASIBLE, + primal_or_dual_infeasible=False, + ), + objective_bounds=result.ObjectiveBounds( + primal_bound=math.inf, + dual_bound=-math.inf, + ), + ), + ) + self.assertTrue(res.bounded()) + + def test_not_bounded_primal_infeasible(self) -> None: + res = result.SolveResult( + termination=result.Termination( + reason=result.TerminationReason.NO_SOLUTION_FOUND, + problem_status=result.ProblemStatus( + primal_status=result.FeasibilityStatus.INFEASIBLE, + dual_status=result.FeasibilityStatus.FEASIBLE, + primal_or_dual_infeasible=False, + ), + objective_bounds=result.ObjectiveBounds( + primal_bound=math.inf, + dual_bound=-math.inf, + ), + ), + ) + self.assertFalse(res.bounded()) + + def test_not_bounded_dual_infeasible(self) -> None: + res = result.SolveResult( + termination=result.Termination( + reason=result.TerminationReason.NO_SOLUTION_FOUND, + problem_status=result.ProblemStatus( + primal_status=result.FeasibilityStatus.FEASIBLE, + dual_status=result.FeasibilityStatus.INFEASIBLE, + primal_or_dual_infeasible=False, + ), + objective_bounds=result.ObjectiveBounds( + primal_bound=math.inf, + dual_bound=-math.inf, + ), + ), + ) + self.assertFalse(res.bounded()) + + +def _make_undetermined_result_proto() -> result_pb2.SolveResultProto: + proto = result_pb2.SolveResultProto( + termination=result_pb2.TerminationProto( + reason=result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND, + limit=result_pb2.LIMIT_TIME, + problem_status=result_pb2.ProblemStatusProto( + primal_status=result_pb2.FEASIBILITY_STATUS_UNDETERMINED, + dual_status=result_pb2.FEASIBILITY_STATUS_UNDETERMINED, + primal_or_dual_infeasible=False, + ), + objective_bounds=result_pb2.ObjectiveBoundsProto( + primal_bound=math.inf, + dual_bound=-math.inf, + ), + ), + solutions=[ + solution_pb2.SolutionProto( + primal_solution=solution_pb2.PrimalSolutionProto( + objective_value=2.0, + variable_values=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[1.0] + ), + feasibility_status=solution_pb2.SOLUTION_STATUS_UNDETERMINED, + ) + ) + ], + ) + proto.solve_stats.problem_status.primal_status = ( + result_pb2.FEASIBILITY_STATUS_UNDETERMINED + ) + proto.solve_stats.problem_status.dual_status = ( + result_pb2.FEASIBILITY_STATUS_UNDETERMINED + ) + proto.solve_stats.problem_status.primal_or_dual_infeasible = False + proto.solve_stats.best_primal_bound = math.inf + proto.solve_stats.best_dual_bound = -math.inf + return proto + + +class SolveResultTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_solve_result_gscip_output(self) -> None: + mod = model.Model(name="test_model") + mod.add_binary_variable() + proto = _make_undetermined_result_proto() + proto.gscip_output.status_detail = "gscip_detail" + res = result.parse_solve_result(proto, mod) + assert res.gscip_specific_output is not None + self.assertEqual("gscip_detail", res.gscip_specific_output.status_detail) + + def test_solve_result_no_gscip_output(self) -> None: + mod = model.Model(name="test_model") + mod.add_binary_variable() + proto = _make_undetermined_result_proto() + res = result.parse_solve_result(proto, mod) + self.assertIsNone(res.gscip_specific_output) + + def test_solve_result_osqp_output(self) -> None: + mod = model.Model(name="test_model") + mod.add_binary_variable() + proto = _make_undetermined_result_proto() + proto.osqp_output.initialized_underlying_solver = False + res = result.parse_solve_result(proto, mod) + assert res.osqp_specific_output is not None + self.assertFalse(res.osqp_specific_output.initialized_underlying_solver) + + def test_solve_result_no_osqp_output(self) -> None: + mod = model.Model(name="test_model") + mod.add_binary_variable() + proto = _make_undetermined_result_proto() + res = result.parse_solve_result(proto, mod) + self.assertIsNone(res.osqp_specific_output) + + def test_solve_result_pdlp_output(self) -> None: + mod = model.Model(name="test_model") + mod.add_binary_variable() + proto = _make_undetermined_result_proto() + proto.pdlp_output.convergence_information.corrected_dual_objective = 2.0 + res = result.parse_solve_result(proto, mod) + assert res.pdlp_specific_output is not None + self.assertEqual( + res.pdlp_specific_output.convergence_information.corrected_dual_objective, + 2.0, + ) + + def test_solve_result_no_pdlp_output(self) -> None: + mod = model.Model(name="test_model") + mod.add_binary_variable() + proto = _make_undetermined_result_proto() + res = result.parse_solve_result(proto, mod) + self.assertIsNone(res.pdlp_specific_output) + + def test_solve_result_from_proto_missing_bounds_in_termination( + self, + ) -> None: + mod = model.Model(name="test_model") + proto = result_pb2.SolveResultProto( + termination=result_pb2.TerminationProto( + reason=result_pb2.TERMINATION_REASON_INFEASIBLE, + detail="", + problem_status=result_pb2.ProblemStatusProto( + primal_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE, + dual_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE, + primal_or_dual_infeasible=False, + ), + ), + solve_stats=result_pb2.SolveStatsProto( + best_primal_bound=10.0, + best_dual_bound=20.0, + ), + ) + res = result.parse_solve_result(proto, mod) + self.assertEqual(10.0, res.termination.objective_bounds.primal_bound) + self.assertEqual(20.0, res.termination.objective_bounds.dual_bound) + self.assertFalse(res.termination.problem_status.primal_or_dual_infeasible) + + def test_solve_result_from_proto_missing_status_in_termination( + self, + ) -> None: + mod = model.Model(name="test_model") + proto = result_pb2.SolveResultProto( + termination=result_pb2.TerminationProto( + reason=result_pb2.TERMINATION_REASON_INFEASIBLE, + detail="", + objective_bounds=result_pb2.ObjectiveBoundsProto( + primal_bound=10.0, dual_bound=20.0 + ), + ), + solve_stats=result_pb2.SolveStatsProto( + problem_status=result_pb2.ProblemStatusProto( + primal_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE, + dual_status=result_pb2.FEASIBILITY_STATUS_FEASIBLE, + primal_or_dual_infeasible=False, + ), + ), + ) + res = result.parse_solve_result(proto, mod) + self.assertEqual( + result.FeasibilityStatus.INFEASIBLE, + res.termination.problem_status.primal_status, + ) + self.assertEqual( + result.FeasibilityStatus.FEASIBLE, + res.termination.problem_status.dual_status, + ) + + def test_solve_result_from_proto_double_infeasible_multiple_rays( + self, + ) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + + proto = result_pb2.SolveResultProto( + termination=result_pb2.TerminationProto( + reason=result_pb2.TERMINATION_REASON_INFEASIBLE, + detail="", + problem_status=result_pb2.ProblemStatusProto( + primal_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE, + dual_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE, + primal_or_dual_infeasible=False, + ), + objective_bounds=result_pb2.ObjectiveBoundsProto( + primal_bound=math.inf, dual_bound=-math.inf + ), + ), + primal_rays=[ + solution_pb2.PrimalRayProto( + variable_values=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1], values=[2.0, 1.0] + ) + ), + solution_pb2.PrimalRayProto( + variable_values=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1], values=[3.0, 2.0] + ) + ), + ], + dual_rays=[ + solution_pb2.DualRayProto( + dual_values=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[4.0] + ), + reduced_costs=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1], values=[10.0, 11.0] + ), + ), + solution_pb2.DualRayProto( + dual_values=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[5.0] + ), + reduced_costs=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1], values=[11.0, 12.0] + ), + ), + ], + ) + proto.solve_stats.node_count = 10 + proto.solve_stats.problem_status.primal_status = ( + result_pb2.FEASIBILITY_STATUS_INFEASIBLE + ) + proto.solve_stats.problem_status.dual_status = ( + result_pb2.FEASIBILITY_STATUS_INFEASIBLE + ) + proto.solve_stats.problem_status.primal_or_dual_infeasible = False + proto.solve_stats.best_primal_bound = math.inf + proto.solve_stats.best_dual_bound = -math.inf + res = result.parse_solve_result(proto, mod) + + self.assertEqual(result.TerminationReason.INFEASIBLE, res.termination.reason) + self.assertEqual("", res.termination.detail) + self.assertIsNone(res.termination.limit) + self.assertEmpty(res.solutions) + self.assertLen(res.primal_rays, 2) + self.assertLen(res.dual_rays, 2) + self.assertDictEqual({x: 2.0, y: 1.0}, res.primal_rays[0].variable_values) + self.assertDictEqual({x: 10.0, y: 11.0}, res.dual_rays[0].reduced_costs) + self.assertDictEqual({c: 4.0}, res.dual_rays[0].dual_values) + self.assertDictEqual({x: 3.0, y: 2.0}, res.primal_rays[1].variable_values) + self.assertDictEqual({x: 11.0, y: 12.0}, res.dual_rays[1].reduced_costs) + self.assertDictEqual({c: 5.0}, res.dual_rays[1].dual_values) + + # solve_stats + self.assertEqual(10, res.solve_stats.node_count) + self.assertEqual( + result.FeasibilityStatus.INFEASIBLE, + res.termination.problem_status.primal_status, + ) + self.assertEqual( + result.FeasibilityStatus.INFEASIBLE, + res.termination.problem_status.dual_status, + ) + self.assertFalse(res.termination.problem_status.primal_or_dual_infeasible) + self.assertEqual(math.inf, res.termination.objective_bounds.primal_bound) + self.assertEqual(-math.inf, res.termination.objective_bounds.dual_bound) + self.assertIsNone(res.gscip_specific_output) + + def test_solve_result_from_feasible_multiple_solutions(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + + proto = result_pb2.SolveResultProto( + termination=result_pb2.TerminationProto( + reason=result_pb2.TERMINATION_REASON_OPTIMAL, + problem_status=result_pb2.ProblemStatusProto( + primal_status=result_pb2.FEASIBILITY_STATUS_FEASIBLE, + dual_status=result_pb2.FEASIBILITY_STATUS_FEASIBLE, + ), + objective_bounds=result_pb2.ObjectiveBoundsProto( + primal_bound=10.0, dual_bound=20.0 + ), + ), + solutions=[ + solution_pb2.SolutionProto( + primal_solution=solution_pb2.PrimalSolutionProto( + objective_value=2.0, + variable_values=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1], values=[2.0, 1.0] + ), + feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE, + ), + dual_solution=solution_pb2.DualSolutionProto( + objective_value=2.0, + dual_values=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[4.0] + ), + reduced_costs=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1], values=[10.0, 11.0] + ), + feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE, + ), + basis=solution_pb2.BasisProto( + constraint_status=solution_pb2.SparseBasisStatusVector( + ids=[0], + values=[solution_pb2.BASIS_STATUS_AT_UPPER_BOUND], + ), + variable_status=solution_pb2.SparseBasisStatusVector( + ids=[0, 1], + values=[ + solution_pb2.BASIS_STATUS_BASIC, + solution_pb2.BASIS_STATUS_AT_LOWER_BOUND, + ], + ), + basic_dual_feasibility=solution_pb2.SOLUTION_STATUS_FEASIBLE, + ), + ), + solution_pb2.SolutionProto( + primal_solution=solution_pb2.PrimalSolutionProto( + objective_value=3.0, + variable_values=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1], values=[3.0, 2.0] + ), + feasibility_status=solution_pb2.SOLUTION_STATUS_INFEASIBLE, + ) + ), + solution_pb2.SolutionProto( + dual_solution=solution_pb2.DualSolutionProto( + objective_value=3.0, + dual_values=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0], values=[5.0] + ), + reduced_costs=sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1], values=[11.0, 12.0] + ), + feasibility_status=solution_pb2.SOLUTION_STATUS_INFEASIBLE, + ) + ), + solution_pb2.SolutionProto( + basis=solution_pb2.BasisProto( + constraint_status=solution_pb2.SparseBasisStatusVector( + ids=[0], values=[solution_pb2.BASIS_STATUS_BASIC] + ), + variable_status=solution_pb2.SparseBasisStatusVector( + ids=[0, 1], + values=[ + solution_pb2.BASIS_STATUS_AT_LOWER_BOUND, + solution_pb2.BASIS_STATUS_AT_UPPER_BOUND, + ], + ), + basic_dual_feasibility=solution_pb2.SOLUTION_STATUS_INFEASIBLE, + ) + ), + ], + ) + + proto.solve_stats.node_count = 10 + proto.solve_stats.problem_status.primal_status = ( + result_pb2.FEASIBILITY_STATUS_FEASIBLE + ) + proto.solve_stats.problem_status.dual_status = ( + result_pb2.FEASIBILITY_STATUS_FEASIBLE + ) + proto.solve_stats.problem_status.primal_or_dual_infeasible = False + proto.solve_stats.best_primal_bound = 10 + proto.solve_stats.best_dual_bound = 10 + res = result.parse_solve_result(proto, mod) + + self.assertEqual(result.TerminationReason.OPTIMAL, res.termination.reason) + self.assertEqual("", res.termination.detail) + self.assertIsNone(res.termination.limit) + self.assertLen(res.solutions, 4) + self.assertEmpty(res.primal_rays) + self.assertEmpty(res.dual_rays) + + # Solution 0 + assert ( + res.solutions[0].primal_solution is not None + and res.solutions[0].dual_solution is not None + and res.solutions[0].basis is not None + ) + self.assertEqual(2.0, res.solutions[0].primal_solution.objective_value) + self.assertDictEqual( + {x: 2.0, y: 1.0}, res.solutions[0].primal_solution.variable_values + ) + self.assertEqual( + solution.SolutionStatus.FEASIBLE, + res.solutions[0].primal_solution.feasibility_status, + ) + self.assertEqual(2.0, res.solutions[0].dual_solution.objective_value) + self.assertDictEqual( + {x: 10.0, y: 11.0}, res.solutions[0].dual_solution.reduced_costs + ) + self.assertDictEqual({c: 4.0}, res.solutions[0].dual_solution.dual_values) + self.assertEqual( + solution.SolutionStatus.FEASIBLE, + res.solutions[0].dual_solution.feasibility_status, + ) + self.assertDictEqual( + {x: solution.BasisStatus.BASIC, y: solution.BasisStatus.AT_LOWER_BOUND}, + res.solutions[0].basis.variable_status, + ) + self.assertDictEqual( + {c: solution.BasisStatus.AT_UPPER_BOUND}, + res.solutions[0].basis.constraint_status, + ) + self.assertEqual( + solution.SolutionStatus.FEASIBLE, + res.solutions[0].basis.basic_dual_feasibility, + ) + + # Solution 1 + assert res.solutions[1].primal_solution is not None + self.assertEqual(3.0, res.solutions[1].primal_solution.objective_value) + self.assertDictEqual( + {x: 3.0, y: 2.0}, res.solutions[1].primal_solution.variable_values + ) + self.assertEqual( + solution.SolutionStatus.INFEASIBLE, + res.solutions[1].primal_solution.feasibility_status, + ) + self.assertIsNone(res.solutions[1].dual_solution) + self.assertIsNone(res.solutions[1].basis) + + # Solution 2 + assert res.solutions[2].dual_solution is not None + self.assertIsNone(res.solutions[2].primal_solution) + self.assertEqual(3.0, res.solutions[2].dual_solution.objective_value) + self.assertDictEqual( + {x: 11.0, y: 12.0}, res.solutions[2].dual_solution.reduced_costs + ) + self.assertDictEqual({c: 5.0}, res.solutions[2].dual_solution.dual_values) + self.assertEqual( + solution.SolutionStatus.INFEASIBLE, + res.solutions[2].dual_solution.feasibility_status, + ) + self.assertIsNone(res.solutions[2].basis) + + # Solution 3 + assert res.solutions[3].basis is not None + self.assertIsNone(res.solutions[3].primal_solution) + self.assertIsNone(res.solutions[3].dual_solution) + self.assertDictEqual( + { + x: solution.BasisStatus.AT_LOWER_BOUND, + y: solution.BasisStatus.AT_UPPER_BOUND, + }, + res.solutions[3].basis.variable_status, + ) + self.assertDictEqual( + {c: solution.BasisStatus.BASIC}, + res.solutions[3].basis.constraint_status, + ) + self.assertEqual( + solution.SolutionStatus.INFEASIBLE, + res.solutions[3].basis.basic_dual_feasibility, + ) + + # solve_stats + self.assertEqual(10, res.solve_stats.node_count) + self.assertEqual( + result.FeasibilityStatus.FEASIBLE, + res.termination.problem_status.primal_status, + ) + self.assertEqual( + result.FeasibilityStatus.FEASIBLE, + res.termination.problem_status.dual_status, + ) + self.assertFalse(res.termination.problem_status.primal_or_dual_infeasible) + self.assertEqual(10, res.termination.objective_bounds.primal_bound) + self.assertEqual(20, res.termination.objective_bounds.dual_bound) + self.assertIsNone(res.gscip_specific_output) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/solution.py b/ortools/math_opt/python/solution.py new file mode 100644 index 0000000000..3347cac49d --- /dev/null +++ b/ortools/math_opt/python/solution.py @@ -0,0 +1,411 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The solution to an optimization problem defined by Model in model.py.""" +import dataclasses +import enum +from typing import Dict, Optional, TypeVar + +from ortools.math_opt import solution_pb2 +from ortools.math_opt.python import model +from ortools.math_opt.python import sparse_containers + + +@enum.unique +class BasisStatus(enum.Enum): + """Status of a variable/constraint in a LP basis. + + Attributes: + FREE: The variable/constraint is free (it has no finite bounds). + AT_LOWER_BOUND: The variable/constraint is at its lower bound (which must be + finite). + AT_UPPER_BOUND: The variable/constraint is at its upper bound (which must be + finite). + FIXED_VALUE: The variable/constraint has identical finite lower and upper + bounds. + BASIC: The variable/constraint is basic. + """ + + FREE = solution_pb2.BASIS_STATUS_FREE + AT_LOWER_BOUND = solution_pb2.BASIS_STATUS_AT_LOWER_BOUND + AT_UPPER_BOUND = solution_pb2.BASIS_STATUS_AT_UPPER_BOUND + FIXED_VALUE = solution_pb2.BASIS_STATUS_FIXED_VALUE + BASIC = solution_pb2.BASIS_STATUS_BASIC + + +@enum.unique +class SolutionStatus(enum.Enum): + """Feasibility of a primal or dual solution as claimed by the solver. + + Attributes: + UNDETERMINED: Solver does not claim a feasibility status. + FEASIBLE: Solver claims the solution is feasible. + INFEASIBLE: Solver claims the solution is infeasible. + """ + + UNDETERMINED = solution_pb2.SOLUTION_STATUS_UNDETERMINED + FEASIBLE = solution_pb2.SOLUTION_STATUS_FEASIBLE + INFEASIBLE = solution_pb2.SOLUTION_STATUS_INFEASIBLE + + +@dataclasses.dataclass +class PrimalSolution: + """A solution to the optimization problem in a Model. + + E.g. consider a simple linear program: + min c * x + s.t. A * x >= b + x >= 0. + A primal solution is assignment values to x. It is feasible if it satisfies + A * x >= b and x >= 0 from above. In the class PrimalSolution variable_values + is x and objective_value is c * x. + + For the general case of a MathOpt optimization model, see go/mathopt-solutions + for details. + + Attributes: + variable_values: The value assigned for each Variable in the model. + objective_value: The value of the objective value at this solution. This + value may not be always populated. + feasibility_status: The feasibility of the solution as claimed by the + solver. + """ + + variable_values: Dict[model.Variable, float] = dataclasses.field( + default_factory=dict + ) + objective_value: float = 0.0 + feasibility_status: SolutionStatus = SolutionStatus.UNDETERMINED + + def to_proto(self) -> solution_pb2.PrimalSolutionProto: + """Returns an equivalent proto for a primal solution.""" + return solution_pb2.PrimalSolutionProto( + variable_values=sparse_containers.to_sparse_double_vector_proto( + self.variable_values + ), + objective_value=self.objective_value, + feasibility_status=self.feasibility_status.value, + ) + + +def parse_primal_solution( + proto: solution_pb2.PrimalSolutionProto, mod: model.Model +) -> PrimalSolution: + """Returns an equivalent PrimalSolution from the input proto.""" + result = PrimalSolution() + result.objective_value = proto.objective_value + result.variable_values = sparse_containers.parse_variable_map( + proto.variable_values, mod + ) + status_proto = proto.feasibility_status + if status_proto == solution_pb2.SOLUTION_STATUS_UNSPECIFIED: + raise ValueError("Primal solution feasibility status should not be UNSPECIFIED") + result.feasibility_status = SolutionStatus(status_proto) + return result + + +@dataclasses.dataclass +class PrimalRay: + """A direction of unbounded objective improvement in an optimization Model. + + Equivalently, a certificate of infeasibility for the dual of the optimization + problem. + + E.g. consider a simple linear program: + min c * x + s.t. A * x >= b + x >= 0. + A primal ray is an x that satisfies: + c * x < 0 + A * x >= 0 + x >= 0. + Observe that given a feasible solution, any positive multiple of the primal + ray plus that solution is still feasible, and gives a better objective + value. A primal ray also proves the dual optimization problem infeasible. + + In the class PrimalRay, variable_values is this x. + + For the general case of a MathOpt optimization model, see + go/mathopt-solutions for details. + + Attributes: + variable_values: The value assigned for each Variable in the model. + """ + + variable_values: Dict[model.Variable, float] = dataclasses.field( + default_factory=dict + ) + + +def parse_primal_ray(proto: solution_pb2.PrimalRayProto, mod: model.Model) -> PrimalRay: + """Returns an equivalent PrimalRay from the input proto.""" + result = PrimalRay() + result.variable_values = sparse_containers.parse_variable_map( + proto.variable_values, mod + ) + return result + + +@dataclasses.dataclass +class DualSolution: + """A solution to the dual of the optimization problem given by a Model. + + E.g. consider the primal dual pair linear program pair: + (Primal)            (Dual) + min c * x             max b * y + s.t. A * x >= b       s.t. y * A + r = c + x >= 0              y, r >= 0. + The dual solution is the pair (y, r). It is feasible if it satisfies the + constraints from (Dual) above. + + Below, y is dual_values, r is reduced_costs, and b * y is objective_value. + + For the general case, see go/mathopt-solutions and go/mathopt-dual (and note + that the dual objective depends on r in the general case). + + Attributes: + dual_values: The value assigned for each LinearConstraint in the model. + reduced_costs: The value assigned for each Variable in the model. + objective_value: The value of the dual objective value at this solution. + This value may not be always populated. + feasibility_status: The feasibility of the solution as claimed by the + solver. + """ + + dual_values: Dict[model.LinearConstraint, float] = dataclasses.field( + default_factory=dict + ) + reduced_costs: Dict[model.Variable, float] = dataclasses.field(default_factory=dict) + objective_value: Optional[float] = None + feasibility_status: SolutionStatus = SolutionStatus.UNDETERMINED + + def to_proto(self) -> solution_pb2.DualSolutionProto: + """Returns an equivalent proto for a dual solution.""" + return solution_pb2.DualSolutionProto( + dual_values=sparse_containers.to_sparse_double_vector_proto( + self.dual_values + ), + reduced_costs=sparse_containers.to_sparse_double_vector_proto( + self.reduced_costs + ), + objective_value=self.objective_value, + feasibility_status=self.feasibility_status.value, + ) + + +def parse_dual_solution( + proto: solution_pb2.DualSolutionProto, mod: model.Model +) -> DualSolution: + """Returns an equivalent DualSolution from the input proto.""" + result = DualSolution() + result.objective_value = ( + proto.objective_value if proto.HasField("objective_value") else None + ) + result.dual_values = sparse_containers.parse_linear_constraint_map( + proto.dual_values, mod + ) + result.reduced_costs = sparse_containers.parse_variable_map( + proto.reduced_costs, mod + ) + status_proto = proto.feasibility_status + if status_proto == solution_pb2.SOLUTION_STATUS_UNSPECIFIED: + raise ValueError("Dual solution feasibility status should not be UNSPECIFIED") + result.feasibility_status = SolutionStatus(status_proto) + return result + + +@dataclasses.dataclass +class DualRay: + """A direction of unbounded objective improvement in an optimization Model. + + A direction of unbounded improvement to the dual of an optimization, + problem; equivalently, a certificate of primal infeasibility. + + E.g. consider the primal dual pair linear program pair: + (Primal)            (Dual) + min c * x             max b * y + s.t. A * x >= b       s.t. y * A + r = c + x >= 0              y, r >= 0. + + The dual ray is the pair (y, r) satisfying: + b * y > 0 + y * A + r = 0 + y, r >= 0. + Observe that adding a positive multiple of (y, r) to dual feasible solution + maintains dual feasibility and improves the objective (proving the dual is + unbounded). The dual ray also proves the primal problem is infeasible. + + In the class DualRay below, y is dual_values and r is reduced_costs. + + For the general case, see go/mathopt-solutions and go/mathopt-dual (and note + that the dual objective depends on r in the general case). + + Attributes: + dual_values: The value assigned for each LinearConstraint in the model. + reduced_costs: The value assigned for each Variable in the model. + """ + + dual_values: Dict[model.LinearConstraint, float] = dataclasses.field( + default_factory=dict + ) + reduced_costs: Dict[model.Variable, float] = dataclasses.field(default_factory=dict) + + +def parse_dual_ray(proto: solution_pb2.DualRayProto, mod: model.Model) -> DualRay: + """Returns an equivalent DualRay from the input proto.""" + result = DualRay() + result.dual_values = sparse_containers.parse_linear_constraint_map( + proto.dual_values, mod + ) + result.reduced_costs = sparse_containers.parse_variable_map( + proto.reduced_costs, mod + ) + return result + + +@dataclasses.dataclass +class Basis: + """A combinatorial characterization for a solution to a linear program. + + The simplex method for solving linear programs always returns a "basic + feasible solution" which can be described combinatorially as a Basis. A basis + assigns a BasisStatus for every variable and linear constraint. + + E.g. consider a standard form LP: + min c * x + s.t. A * x = b + x >= 0 + that has more variables than constraints and with full row rank A. + + Let n be the number of variables and m the number of linear constraints. A + valid basis for this problem can be constructed as follows: + * All constraints will have basis status FIXED. + * Pick m variables such that the columns of A are linearly independent and + assign the status BASIC. + * Assign the status AT_LOWER for the remaining n - m variables. + + The basic solution for this basis is the unique solution of A * x = b that has + all variables with status AT_LOWER fixed to their lower bounds (all zero). The + resulting solution is called a basic feasible solution if it also satisfies + x >= 0. + + See go/mathopt-basis for treatment of the general case and an explanation of + how a dual solution is determined for a basis. + + Attributes: + variable_status: The basis status for each variable in the model. + constraint_status: The basis status for each linear constraint in the model. + basic_dual_feasibility: This is an advanced feature used by MathOpt to + characterize feasibility of suboptimal LP solutions (optimal solutions + will always have status SolutionStatus.FEASIBLE). For single-sided LPs it + should be equal to the feasibility status of the associated dual solution. + For two-sided LPs it may be different in some edge cases (e.g. incomplete + solves with primal simplex). For more details see + go/mathopt-basis-advanced#dualfeasibility. If you are providing a starting + basis via ModelSolveParameters.initial_basis, this value is ignored. It is + only relevant for the basis returned by Solution.basis. This is an + advanced status. For single-sided LPs it should be equal to the + feasibility status of the associated dual solution. For two-sided LPs it + may be different in some edge cases (e.g. incomplete solves with primal + simplex). For more details see go/mathopt-basis-advanced#dualfeasibility. + """ + + variable_status: Dict[model.Variable, BasisStatus] = dataclasses.field( + default_factory=dict + ) + constraint_status: Dict[model.LinearConstraint, BasisStatus] = dataclasses.field( + default_factory=dict + ) + basic_dual_feasibility: SolutionStatus = SolutionStatus.UNDETERMINED + + def to_proto(self) -> solution_pb2.BasisProto: + """Returns an equivalent proto for the basis.""" + return solution_pb2.BasisProto( + variable_status=_to_sparse_basis_status_vector_proto(self.variable_status), + constraint_status=_to_sparse_basis_status_vector_proto( + self.constraint_status + ), + basic_dual_feasibility=self.basic_dual_feasibility.value, + ) + + +def parse_basis(proto: solution_pb2.BasisProto, mod: model.Model) -> Basis: + """Returns an equivalent Basis to the input proto.""" + result = Basis() + for index, vid in enumerate(proto.variable_status.ids): + status_proto = proto.variable_status.values[index] + if status_proto == solution_pb2.BASIS_STATUS_UNSPECIFIED: + raise ValueError("Variable basis status should not be UNSPECIFIED") + result.variable_status[mod.get_variable(vid)] = BasisStatus(status_proto) + for index, cid in enumerate(proto.constraint_status.ids): + status_proto = proto.constraint_status.values[index] + if status_proto == solution_pb2.BASIS_STATUS_UNSPECIFIED: + raise ValueError("Constraint basis status should not be UNSPECIFIED") + result.constraint_status[mod.get_linear_constraint(cid)] = BasisStatus( + status_proto + ) + status_proto = proto.basic_dual_feasibility + if status_proto == solution_pb2.SOLUTION_STATUS_UNSPECIFIED: + raise ValueError("Basic dual feasibility status should not be UNSPECIFIED") + result.basic_dual_feasibility = SolutionStatus(status_proto) + return result + + +T = TypeVar("T", model.Variable, model.LinearConstraint) + + +def _to_sparse_basis_status_vector_proto( + terms: Dict[T, BasisStatus] +) -> solution_pb2.SparseBasisStatusVector: + """Converts a basis vector from a python Dict to a protocol buffer.""" + result = solution_pb2.SparseBasisStatusVector() + if terms: + id_and_status = sorted( + (key.id, status.value) for (key, status) in terms.items() + ) + ids, values = zip(*id_and_status) + result.ids[:] = ids + result.values[:] = values + return result + + +@dataclasses.dataclass +class Solution: + """A solution to the optimization problem in a Model.""" + + primal_solution: Optional[PrimalSolution] = None + dual_solution: Optional[DualSolution] = None + basis: Optional[Basis] = None + + def to_proto(self) -> solution_pb2.SolutionProto: + """Returns an equivalent proto for a solution.""" + return solution_pb2.SolutionProto( + primal_solution=self.primal_solution.to_proto() + if self.primal_solution is not None + else None, + dual_solution=self.dual_solution.to_proto() + if self.dual_solution is not None + else None, + basis=self.basis.to_proto() if self.basis is not None else None, + ) + + +def parse_solution(proto: solution_pb2.SolutionProto, mod: model.Model) -> Solution: + """Returns a Solution equivalent to the input proto.""" + result = Solution() + if proto.HasField("primal_solution"): + result.primal_solution = parse_primal_solution(proto.primal_solution, mod) + if proto.HasField("dual_solution"): + result.dual_solution = parse_dual_solution(proto.dual_solution, mod) + result.basis = parse_basis(proto.basis, mod) if proto.HasField("basis") else None + return result diff --git a/ortools/math_opt/python/solution_test.py b/ortools/math_opt/python/solution_test.py new file mode 100644 index 0000000000..87d3e35304 --- /dev/null +++ b/ortools/math_opt/python/solution_test.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from ortools.math_opt import solution_pb2 +from ortools.math_opt.python import model +from ortools.math_opt.python import solution +from ortools.math_opt.python.testing import compare_proto + + +class ParsePrimalSolutionTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_empty_primal_solution_proto_round_trip(self) -> None: + mod = model.Model(name="test_model") + empty_solution = solution.PrimalSolution( + objective_value=2.0, + feasibility_status=solution.SolutionStatus.UNDETERMINED, + ) + empty_proto = empty_solution.to_proto() + expected_proto = solution_pb2.PrimalSolutionProto() + expected_proto.objective_value = 2.0 + expected_proto.feasibility_status = solution_pb2.SOLUTION_STATUS_UNDETERMINED + self.assert_protos_equiv(expected_proto, empty_proto) + round_trip_solution = solution.parse_primal_solution(empty_proto, mod) + self.assertEmpty(round_trip_solution.variable_values) + + def test_primal_solution_proto_round_trip(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + proto = solution_pb2.PrimalSolutionProto( + objective_value=2.0, + feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE, + ) + proto.variable_values.ids[:] = [0, 2] + proto.variable_values.values[:] = [1.0, 0.0] + actual = solution.parse_primal_solution(proto, mod) + self.assertDictEqual({x: 1.0, z: 0.0}, actual.variable_values) + self.assertEqual(2.0, actual.objective_value) + self.assertEqual(solution.SolutionStatus.FEASIBLE, actual.feasibility_status) + self.assert_protos_equiv(proto, actual.to_proto()) + + def test_primal_solution_unspecified_feasibility(self) -> None: + mod = model.Model(name="test_model") + proto = solution_pb2.PrimalSolutionProto( + objective_value=2.0, + feasibility_status=solution_pb2.SOLUTION_STATUS_UNSPECIFIED, + ) + with self.assertRaisesRegex( + ValueError, "Primal solution feasibility.*UNSPECIFIED" + ): + solution.parse_primal_solution(proto, mod) + + +class ParsePrimalRayTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_parse(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + proto = solution_pb2.PrimalRayProto() + proto.variable_values.ids[:] = [0, 1] + proto.variable_values.values[:] = [1.0, 1.0] + actual = solution.parse_primal_ray(proto, mod) + self.assertDictEqual({x: 1.0, y: 1.0}, actual.variable_values) + + +class ParseDualSolutionTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_empty_primal_solution_proto_round_trip(self) -> None: + mod = model.Model(name="test_model") + empty_solution = solution.DualSolution( + objective_value=2.0, + feasibility_status=solution.SolutionStatus.UNDETERMINED, + ) + empty_proto = empty_solution.to_proto() + expected_proto = solution_pb2.DualSolutionProto() + expected_proto.objective_value = 2.0 + expected_proto.feasibility_status = solution_pb2.SOLUTION_STATUS_UNDETERMINED + self.assert_protos_equiv(expected_proto, empty_proto) + round_trip_solution = solution.parse_dual_solution(empty_proto, mod) + self.assertEmpty(round_trip_solution.dual_values) + self.assertEmpty(round_trip_solution.reduced_costs) + + def test_no_obj(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + proto = solution_pb2.DualSolutionProto() + proto.dual_values.ids[:] = [0, 1] + proto.dual_values.values[:] = [0.0, 1.0] + proto.reduced_costs.ids[:] = [0, 1] + proto.reduced_costs.values[:] = [10.0, 0.0] + proto.feasibility_status = solution_pb2.SOLUTION_STATUS_FEASIBLE + actual = solution.parse_dual_solution(proto, mod) + self.assertDictEqual({x: 10.0, y: 0.0}, actual.reduced_costs) + self.assertDictEqual({c: 0.0, d: 1.0}, actual.dual_values) + self.assertIsNone(actual.objective_value) + self.assertEqual(solution.SolutionStatus.FEASIBLE, actual.feasibility_status) + self.assert_protos_equiv(proto, actual.to_proto()) + + def test_with_obj(self) -> None: + mod = model.Model(name="test_model") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + proto = solution_pb2.DualSolutionProto(objective_value=3.0) + proto.dual_values.ids[:] = [0] + proto.dual_values.values[:] = [5.0] + proto.feasibility_status = solution_pb2.SOLUTION_STATUS_INFEASIBLE + actual = solution.parse_dual_solution(proto, mod) + self.assertEmpty(actual.reduced_costs) + self.assertDictEqual({c: 5.0}, actual.dual_values) + self.assertEqual(3.0, actual.objective_value) + self.assertEqual(solution.SolutionStatus.INFEASIBLE, actual.feasibility_status) + self.assert_protos_equiv(proto, actual.to_proto()) + + def test_dual_solution_unspecified_feasibility(self) -> None: + mod = model.Model(name="test_model") + proto = solution_pb2.DualSolutionProto( + objective_value=2.0, + feasibility_status=solution_pb2.SOLUTION_STATUS_UNSPECIFIED, + ) + with self.assertRaisesRegex( + ValueError, "Dual solution feasibility.*UNSPECIFIED" + ): + solution.parse_dual_solution(proto, mod) + + +class ParseDualRayTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_parse(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + proto = solution_pb2.DualRayProto() + proto.dual_values.ids[:] = [0, 1] + proto.dual_values.values[:] = [0.0, 1.0] + proto.reduced_costs.ids[:] = [0, 1] + proto.reduced_costs.values[:] = [10.0, 0.0] + actual = solution.parse_dual_ray(proto, mod) + self.assertDictEqual({x: 10.0, y: 0.0}, actual.reduced_costs) + self.assertDictEqual({c: 0.0, d: 1.0}, actual.dual_values) + + +class BasisTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_empty_basis_proto_round_trip(self) -> None: + mod = model.Model(name="test_model") + empty_basis = solution.Basis() + empty_proto = empty_basis.to_proto() + expected_proto = solution_pb2.BasisProto() + expected_proto.basic_dual_feasibility = ( + solution_pb2.SOLUTION_STATUS_UNDETERMINED + ) + self.assert_protos_equiv(expected_proto, empty_proto) + round_trip_basis = solution.parse_basis(empty_proto, mod) + self.assertEmpty(round_trip_basis.constraint_status) + self.assertEmpty(round_trip_basis.variable_status) + + def test_basis_proto_round_trip(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + basis = solution.Basis() + basis.variable_status[x] = solution.BasisStatus.AT_LOWER_BOUND + basis.variable_status[y] = solution.BasisStatus.BASIC + basis.constraint_status[c] = solution.BasisStatus.BASIC + basis.constraint_status[d] = solution.BasisStatus.AT_UPPER_BOUND + basis.basic_dual_feasibility = solution.SolutionStatus.FEASIBLE + basis_proto = basis.to_proto() + expected_proto = solution_pb2.BasisProto() + expected_proto.constraint_status.ids[:] = [0, 1] + expected_proto.constraint_status.values[:] = [ + solution_pb2.BASIS_STATUS_BASIC, + solution_pb2.BASIS_STATUS_AT_UPPER_BOUND, + ] + expected_proto.variable_status.ids[:] = [0, 1] + expected_proto.variable_status.values[:] = [ + solution_pb2.BASIS_STATUS_AT_LOWER_BOUND, + solution_pb2.BASIS_STATUS_BASIC, + ] + expected_proto.basic_dual_feasibility = solution_pb2.SOLUTION_STATUS_FEASIBLE + self.assert_protos_equiv(expected_proto, basis_proto) + round_trip_basis = solution.parse_basis(basis_proto, mod) + self.assertDictEqual( + {c: solution.BasisStatus.BASIC, d: solution.BasisStatus.AT_UPPER_BOUND}, + round_trip_basis.constraint_status, + ) + self.assertDictEqual( + {x: solution.BasisStatus.AT_LOWER_BOUND, y: solution.BasisStatus.BASIC}, + round_trip_basis.variable_status, + ) + + def test_constraint_status_unspecified(self) -> None: + mod = model.Model(name="test_model") + mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + basis_proto = solution_pb2.BasisProto() + basis_proto.constraint_status.ids[:] = [0, 1] + basis_proto.constraint_status.values[:] = [ + solution_pb2.BASIS_STATUS_UNSPECIFIED, + solution_pb2.BASIS_STATUS_AT_UPPER_BOUND, + ] + basis_proto.variable_status.ids[:] = [0, 1] + basis_proto.variable_status.values[:] = [ + solution_pb2.BASIS_STATUS_AT_LOWER_BOUND, + solution_pb2.BASIS_STATUS_BASIC, + ] + basis_proto.basic_dual_feasibility = solution_pb2.SOLUTION_STATUS_FEASIBLE + with self.assertRaisesRegex(ValueError, "Constraint basis.*UNSPECIFIED"): + solution.parse_basis(basis_proto, mod) + + def test_variable_status_unspecified(self) -> None: + mod = model.Model(name="test_model") + mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + basis_proto = solution_pb2.BasisProto() + basis_proto.constraint_status.ids[:] = [0, 1] + basis_proto.constraint_status.values[:] = [ + solution_pb2.BASIS_STATUS_BASIC, + solution_pb2.BASIS_STATUS_AT_UPPER_BOUND, + ] + basis_proto.variable_status.ids[:] = [0, 1] + basis_proto.variable_status.values[:] = [ + solution_pb2.BASIS_STATUS_UNSPECIFIED, + solution_pb2.BASIS_STATUS_BASIC, + ] + basis_proto.basic_dual_feasibility = solution_pb2.SOLUTION_STATUS_FEASIBLE + with self.assertRaisesRegex(ValueError, "Variable basis.*UNSPECIFIED"): + solution.parse_basis(basis_proto, mod) + + def test_basic_dual_feasibility_unspecified(self) -> None: + mod = model.Model(name="test_model") + mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + basis_proto = solution_pb2.BasisProto() + basis_proto.constraint_status.ids[:] = [0, 1] + basis_proto.constraint_status.values[:] = [ + solution_pb2.BASIS_STATUS_BASIC, + solution_pb2.BASIS_STATUS_AT_UPPER_BOUND, + ] + basis_proto.variable_status.ids[:] = [0, 1] + basis_proto.variable_status.values[:] = [ + solution_pb2.BASIS_STATUS_AT_UPPER_BOUND, + solution_pb2.BASIS_STATUS_BASIC, + ] + basis_proto.basic_dual_feasibility = solution_pb2.SOLUTION_STATUS_UNSPECIFIED + with self.assertRaisesRegex(ValueError, "Basic dual feasibility.*UNSPECIFIED"): + solution.parse_basis(basis_proto, mod) + + +class ParseSolutionTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_solution_proto_round_trip(self) -> None: + mod = model.Model(name="test_model") + mod.add_variable() + mod.add_variable() + mod.add_linear_constraint() + mod.add_linear_constraint() + primal_solution = solution_pb2.PrimalSolutionProto( + objective_value=2.0, + feasibility_status=solution_pb2.SOLUTION_STATUS_INFEASIBLE, + ) + primal_solution.variable_values.ids[:] = [0, 1] + primal_solution.variable_values.values[:] = [1.0, 0.0] + dual_solution = solution_pb2.DualSolutionProto( + objective_value=2.0, + feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE, + ) + dual_solution.dual_values.ids[:] = [0, 1] + dual_solution.dual_values.values[:] = [0.0, 1.0] + dual_solution.reduced_costs.ids[:] = [0, 1] + dual_solution.reduced_costs.values[:] = [10.0, 0.0] + basis = solution_pb2.BasisProto() + basis.constraint_status.ids[:] = [0, 1] + basis.constraint_status.values[:] = [ + solution_pb2.BASIS_STATUS_BASIC, + solution_pb2.BASIS_STATUS_AT_UPPER_BOUND, + ] + basis.variable_status.ids[:] = [0, 1] + basis.variable_status.values[:] = [ + solution_pb2.BASIS_STATUS_AT_LOWER_BOUND, + solution_pb2.BASIS_STATUS_BASIC, + ] + basis.basic_dual_feasibility = solution_pb2.SOLUTION_STATUS_FEASIBLE + proto = solution_pb2.SolutionProto( + primal_solution=primal_solution, + dual_solution=dual_solution, + basis=basis, + ) + actual = solution.parse_solution(proto, mod) + self.assert_protos_equiv(proto, actual.to_proto()) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/solve.py b/ortools/math_opt/python/solve.py new file mode 100644 index 0000000000..dd7588a0ac --- /dev/null +++ b/ortools/math_opt/python/solve.py @@ -0,0 +1,270 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Solve optimization problems, as defined by Model in model.py.""" +import types +from typing import Callable, Optional + +from ortools.math_opt import parameters_pb2 +from ortools.math_opt.core.python import solver +from ortools.math_opt.python import callback +from ortools.math_opt.python import compute_infeasible_subsystem_result +from ortools.math_opt.python import message_callback +from ortools.math_opt.python import model +from ortools.math_opt.python import model_parameters +from ortools.math_opt.python import parameters +from ortools.math_opt.python import result +from pybind11_abseil.status import StatusNotOk + +SolveCallback = Callable[[callback.CallbackData], callback.CallbackResult] + + +def solve( + opt_model: model.Model, + solver_type: parameters.SolverType, + *, + params: Optional[parameters.SolveParameters] = None, + model_params: Optional[model_parameters.ModelSolveParameters] = None, + msg_cb: Optional[message_callback.SolveMessageCallback] = None, + callback_reg: Optional[callback.CallbackRegistration] = None, + cb: Optional[SolveCallback] = None, +) -> result.SolveResult: + """Solves an optimization model. + + Thread-safety: this function must not be called while modifying the Model + (adding variables...). Some solvers may add more restriction regarding + threading. Please see SolverType::XXX documentation for details. + + Args: + opt_model: The optimization model. + solver_type: The underlying solver to use. + params: Configuration of the underlying solver. + model_params: Configuration of the solver that is model specific. + msg_cb: A callback that gives back the underlying solver's logs by the line. + callback_reg: Configures when the callback will be invoked (if provided) and + what data will be collected to access in the callback. + cb: A callback that will be called periodically as the solver runs. + + Returns: + A SolveResult containing the termination reason, solution(s) and stats. + + Raises: + RuntimeError: On a solve error. + """ + # First, initialize optional arguments that were not set to default values. + # Note that in python, default arguments must be immutable, and these are not. + params = params or parameters.SolveParameters() + model_params = model_params or model_parameters.ModelSolveParameters() + callback_reg = callback_reg or callback.CallbackRegistration() + model_proto = opt_model.export_model() + proto_cb = None + if cb is not None: + proto_cb = lambda x: cb( # pylint: disable=g-long-lambda + callback.parse_callback_data(x, opt_model) + ).to_proto() + # Solve + try: + proto_result = solver.solve( + model_proto, + solver_type.value, + parameters_pb2.SolverInitializerProto(), + params.to_proto(), + model_params.to_proto(), + msg_cb, + callback_reg.to_proto(), + proto_cb, + None, + ) + except StatusNotOk as e: + raise RuntimeError(str(e)) from None + return result.parse_solve_result(proto_result, opt_model) + + +def compute_infeasible_subsystem( + opt_model: model.Model, + solver_type: parameters.SolverType, + *, + params: Optional[parameters.SolveParameters] = None, + msg_cb: Optional[message_callback.SolveMessageCallback] = None, +) -> compute_infeasible_subsystem_result.ComputeInfeasibleSubsystemResult: + """Computes an infeasible subsystem of the input model. + + Args: + opt_model: The optimization model to check for infeasibility. + solver_type: Which solver to use to compute the infeasible subsystem. As of + August 2023, the only supported solver is Gurobi. + params: Configuration of the underlying solver. + msg_cb: A callback that gives back the underlying solver's logs by the line. + + Returns: + An `ComputeInfeasibleSubsystemResult` where `feasibility` indicates if the + problem was proven infeasible. + + Throws: + RuntimeError: on invalid inputs or an internal solver error. + """ + params = params or parameters.SolveParameters() + model_proto = opt_model.export_model() + # Solve + try: + proto_result = solver.compute_infeasible_subsystem( + model_proto, + solver_type.value, + parameters_pb2.SolverInitializerProto(), + params.to_proto(), + msg_cb, + None, + ) + except StatusNotOk as e: + raise RuntimeError(str(e)) from None + return ( + compute_infeasible_subsystem_result.parse_compute_infeasible_subsystem_result( + proto_result, opt_model + ) + ) + + +class IncrementalSolver: + """Solve an optimization multiple times, with modifications between solves. + + Prefer calling simply solve() above in most cases when incrementalism is not + needed. + + Thread-safety: The __init__(), solve() methods must not be called while + modifying the Model (adding variables...). The user is expected to use proper + synchronization primitives to serialize changes to the model and the use of + this object. Note though that it is safe to call methods from different + IncrementalSolver instances on the same Model concurrently. The solve() method + must not be called concurrently on different threads for the same + IncrementalSolver. Some solvers may add more restriction regarding + threading. Please see to SolverType::XXX documentation for details. + + This class references some resources that are freed when it is garbage + collected (which should usually happen when the last reference is lost). In + particular, it references some C++ objects. Although it is not mandatory, it + is recommended to free those as soon as possible. To do so it is possible to + use this class in the `with` statement: + + with IncrementalSolver(model, SolverType.GLOP) as solver: + ... + + When it is not possible to use `with`, the close() method can be called. + """ + + def __init__(self, opt_model: model.Model, solver_type: parameters.SolverType): + self._model = opt_model + self._solver_type = solver_type + self._update_tracker = self._model.add_update_tracker() + try: + self._proto_solver = solver.new( + solver_type.value, + self._model.export_model(), + parameters_pb2.SolverInitializerProto(), + ) + except StatusNotOk as e: + raise RuntimeError(str(e)) from None + self._closed = False + + def solve( + self, + *, + params: Optional[parameters.SolveParameters] = None, + model_params: Optional[model_parameters.ModelSolveParameters] = None, + msg_cb: Optional[message_callback.SolveMessageCallback] = None, + callback_reg: Optional[callback.CallbackRegistration] = None, + cb: Optional[SolveCallback] = None, + ) -> result.SolveResult: + """Solves the current optimization model. + + Args: + params: The non-model specific solve parameters. + model_params: The model specific solve parameters. + msg_cb: An optional callback for solver messages. + callback_reg: The parameters controlling when cb is called. + cb: An optional callback for LP/MIP events. + + Returns: + The result of the solve. + + Raises: + RuntimeError: If called after being closed, or on a solve error. + """ + if self._closed: + raise RuntimeError("the solver is closed") + + update = self._update_tracker.export_update() + if update is not None: + try: + if not self._proto_solver.update(update): + self._proto_solver = solver.new( + self._solver_type.value, + self._model.export_model(), + parameters_pb2.SolverInitializerProto(), + ) + except StatusNotOk as e: + raise RuntimeError(str(e)) from None + self._update_tracker.advance_checkpoint() + params = params or parameters.SolveParameters() + model_params = model_params or model_parameters.ModelSolveParameters() + callback_reg = callback_reg or callback.CallbackRegistration() + proto_cb = None + if cb is not None: + proto_cb = lambda x: cb( # pylint: disable=g-long-lambda + callback.parse_callback_data(x, self._model) + ).to_proto() + try: + result_proto = self._proto_solver.solve( + params.to_proto(), + model_params.to_proto(), + msg_cb, + callback_reg.to_proto(), + proto_cb, + None, + ) + except StatusNotOk as e: + raise RuntimeError(str(e)) from None + return result.parse_solve_result(result_proto, self._model) + + def close(self) -> None: + """Closes this solver, freeing all its resources. + + This is optional, the code is correct without calling this function. See the + class documentation for details. + + After a solver has been closed, it can't be used anymore. Prefer using the + context manager API when possible instead of calling close() directly: + + with IncrementalSolver(model, SolverType.GLOP) as solver: + ... + """ + if self._closed: + return + self._closed = True + + del self._model + del self._solver_type + del self._update_tracker + del self._proto_solver + + def __enter__(self) -> "IncrementalSolver": + """Returns the solver itself.""" + return self + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[types.TracebackType], + ) -> None: + """Closes the solver.""" + self.close() diff --git a/ortools/math_opt/python/solve_gurobi_test.py b/ortools/math_opt/python/solve_gurobi_test.py new file mode 100644 index 0000000000..2cec282ec0 --- /dev/null +++ b/ortools/math_opt/python/solve_gurobi_test.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for solve.py that require using Gurobi as the underlying solver. + +These tests are in a separate file because Gurobi can only run on a licensed +machine. +""" + +import unittest +from ortools.math_opt.python import callback +from ortools.math_opt.python import compute_infeasible_subsystem_result +from ortools.math_opt.python import model +from ortools.math_opt.python import parameters +from ortools.math_opt.python import result +from ortools.math_opt.python import solve + + +_Bounds = compute_infeasible_subsystem_result.ModelSubsetBounds + + +class SolveTest(unittest.TestCase): + def test_callback(self) -> None: + mod = model.Model(name="test_model") + # Solve the problem: + # max x + 2 * y + # s.t. x + y <= 1 (added in callback) + # x, y in {0, 1} + # Primal optimal: [x, y] = [0.0, 1.0] + x = mod.add_binary_variable(name="x") + y = mod.add_binary_variable(name="y") + mod.objective.is_maximize = True + mod.objective.set_linear_coefficient(x, 1.0) + mod.objective.set_linear_coefficient(y, 2.0) + + def cb(cb_data: callback.CallbackData) -> callback.CallbackResult: + cb_res = callback.CallbackResult() + if cb_data.solution[x] + cb_data.solution[y] >= 1 + 1e-4: + cb_res.add_lazy_constraint(x + y <= 1.0) + return cb_res + + cb_reg = callback.CallbackRegistration() + cb_reg.events.add(callback.Event.MIP_SOLUTION) + cb_reg.add_lazy_constraints = True + params = parameters.SolveParameters(enable_output=True) + res = solve.solve( + mod, + parameters.SolverType.GUROBI, + params=params, + callback_reg=cb_reg, + cb=cb, + ) + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertAlmostEqual(2.0, res.termination.objective_bounds.primal_bound) + + def test_compute_infeasible_subsystem_infeasible(self): + mod = model.Model() + x = mod.add_variable(lb=0.0, ub=1.0) + y = mod.add_variable(lb=0.0, ub=1.0) + z = mod.add_variable(lb=0.0, ub=1.0) + mod.add_linear_constraint(x + y <= 4.0) + d = mod.add_linear_constraint(x + z >= 3.0) + + iis = solve.compute_infeasible_subsystem(mod, parameters.SolverType.GUROBI) + self.assertTrue(iis.is_minimal) + self.assertEqual(iis.feasibility, result.FeasibilityStatus.INFEASIBLE) + self.assertDictEqual( + iis.infeasible_subsystem.variable_bounds, + {x: _Bounds(upper=True), z: _Bounds(upper=True)}, + ) + self.assertDictEqual( + iis.infeasible_subsystem.linear_constraints, {d: _Bounds(lower=True)} + ) + self.assertEmpty(iis.infeasible_subsystem.variable_integrality) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/solve_test.py b/ortools/math_opt/python/solve_test.py new file mode 100644 index 0000000000..d1c6740445 --- /dev/null +++ b/ortools/math_opt/python/solve_test.py @@ -0,0 +1,558 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import io +from typing import Dict, List, Union + +import unittest +from ortools.math_opt.core.python import solver as core_solver +from ortools.math_opt.python import message_callback +from ortools.math_opt.python import model +from ortools.math_opt.python import model_parameters +from ortools.math_opt.python import parameters +from ortools.math_opt.python import result +from ortools.math_opt.python import solve +from ortools.math_opt.python import sparse_containers + +VarOrConstraintDict = Union[ + Dict[model.Variable, float], Dict[model.LinearConstraint, float] +] + +# This string appears in the logs if and only if SCIP is doing an incremental +# solve. +_SCIP_LOG_SOLVE_WAS_INCREMENTAL = ( + "feasible solutions given by solution candidate storage" +) + + +def _list_is_near(v1: List[float], v2: List[float], tolerance: float = 1e-5) -> bool: + if len(v1) != len(v2): + return False + for i, v in enumerate(v1): + if abs(v2[i] - v) > tolerance: + return False + return True + + +class SolveTest(unittest.TestCase): + def _assert_dict_almost_equal( + self, expected: VarOrConstraintDict, actual: VarOrConstraintDict, places=5 + ): + act_keys = set(actual.keys()) + exp_keys = set(expected.keys()) + self.assertSetEqual( + exp_keys, + act_keys, + msg=f"actual keys: {act_keys} not equal expected keys: {exp_keys}", + ) + for k, v in expected.items(): + self.assertAlmostEqual( + v, + actual[k], + places, + msg=f"actual: {actual} and expected: {expected} disagree on key: {k}", + ) + + def test_solve_error(self) -> None: + mod = model.Model(name="test_model") + mod.add_variable(lb=1.0, ub=-1.0, name="x1") + with self.assertRaisesRegex( + RuntimeError, "variables.*lower_bound > upper_bound" + ): + solve.solve(mod, parameters.SolverType.GLOP) + + def test_qp_solve(self) -> None: + mod = model.Model(name="test_model") + # Same model as TEST_P(QpDualsTest, GeneralQp1) in solver_test/qp_test.cc. + # Primal: + # min x_0^2 + x_0x_1 + 3x_1^2 - 2x_0 + # s.t. 2 <= x_0 + 2x_1 <= inf + # 0 <= x_0 <= inf + # 0 <= x_1 <= inf + # + # Optimal solution: x* = (1.6, 0.2). + # + # Dual (go/mathopt-qp-dual): + # max -x_0^2 - x_0x_1 - 3x_1^2 + 2y_0 + # s.t. y_0 + r_0 = 2x_0 + x_1 - 2 + # 2y_0 + r_1 = x_0 + 6x_1 + # y_0 >= 0 + # r_0 >= 0 + # r_1 >= 0 + # + # Optimal solution: x* = (1.6, 0.2), y* = (1.4), r* = (0, 0). + x0 = mod.add_variable(lb=0.0, name="x0") + x1 = mod.add_variable(lb=0.0, name="x1") + mod.minimize(x0 * x0 + x0 * x1 + 3 * x1 * x1 - 2 * x0) + c = mod.add_linear_constraint(x0 + 2 * x1 >= 2) + res = solve.solve(mod, parameters.SolverType.OSQP) + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res.solutions), 1) + assert ( + res.solutions[0].primal_solution is not None + and res.solutions[0].dual_solution is not None + ) + self.assertAlmostEqual( + -0.2, res.solutions[0].primal_solution.objective_value, 4 + ) + self._assert_dict_almost_equal( + {x0: 1.6, x1: 0.2}, res.solutions[0].primal_solution.variable_values, 4 + ) + dual = res.solutions[0].dual_solution + self.assertAlmostEqual(-0.2, dual.objective_value, 4) + self._assert_dict_almost_equal({c: 1.4}, dual.dual_values, 4) + self._assert_dict_almost_equal({x0: 0.0, x1: 0.0}, dual.reduced_costs, 4) + + def test_lp_solve(self) -> None: + mod = model.Model(name="test_model") + # Solve the problem: + # max x1 + 2 * x2 + # s.t. x1 + x2 <= 1 + # x1, x2 in [0, 1] + # Primal optimal: [x1, x2] = [0.0, 1.0] + # + # Following: go/mathopt-dual#primal-dual-optimal-pairs, the optimal dual + # solution [y, r1, r2] for [x1, x2] = [0.0, 1.0] must satisfy: + # y + r1 = 1 + # y + r2 = 2 + # y >= 0 + # r1 <= 0 + # r2 >= 0 + # We see that any convex combination of [1, 0, 1] and [2, -1, 0] gives a + # dual optimal solution. + x1 = mod.add_variable(lb=0.0, ub=1.0, name="x1") + x2 = mod.add_variable(lb=0.0, ub=1.0, name="x2") + mod.objective.is_maximize = True + mod.objective.set_linear_coefficient(x1, 1.0) + mod.objective.set_linear_coefficient(x2, 2.0) + c = mod.add_linear_constraint(ub=1.0, name="c") + c.set_coefficient(x1, 1.0) + c.set_coefficient(x2, 1.0) + res = solve.solve(mod, parameters.SolverType.GLOP) + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertAlmostEqual(2.0, res.termination.objective_bounds.primal_bound) + self.assertGreaterEqual(len(res.solutions), 1) + assert ( + res.solutions[0].primal_solution is not None + and res.solutions[0].dual_solution is not None + ) + self.assertAlmostEqual(2.0, res.solutions[0].primal_solution.objective_value) + self._assert_dict_almost_equal( + {x1: 0.0, x2: 1.0}, res.solutions[0].primal_solution.variable_values + ) + dual = res.solutions[0].dual_solution + self.assertAlmostEqual(2.0, dual.objective_value) + self.assertSetEqual({c}, set(dual.dual_values.keys())) + self.assertSetEqual({x1, x2}, set(dual.reduced_costs.keys())) + # Possible values for [y, r1, r2] are [1, 0, 1] and [2, -1, 0]. + dual_vec = [ + dual.dual_values[c], + dual.reduced_costs[x1], + dual.reduced_costs[x2], + ] + # Warning: the code below assumes the returned solution is a basic solution. + # If a non-simplex solver was used, we could get any convex combination of + # the vectors below. + expected1 = [1.0, 0.0, 1.0] + expected2 = [2.0, -1.0, 0.0] + self.assertTrue( + _list_is_near(expected1, dual_vec) or _list_is_near(expected2, dual_vec), + msg=f"dual_vec is {dual_vec}; expected {expected1} or {expected2}", + ) + + def test_filters(self) -> None: + mod = model.Model(name="test_model") + # Solve the problem: + # max x + y + z + # s.t. x + y <= 1 + # y + z <= 1 + # x, y, z in [0, 1] + # Primal optimal: [x, y, z] = [1.0, 0.0, 1.0] + x = mod.add_variable(lb=0.0, ub=1.0, name="x") + y = mod.add_variable(lb=0.0, ub=1.0, name="y") + z = mod.add_variable(lb=0.0, ub=1.0, name="z") + mod.objective.is_maximize = True + mod.objective.set_linear_coefficient(x, 1.0) + mod.objective.set_linear_coefficient(y, 1.0) + mod.objective.set_linear_coefficient(z, 1.0) + c = mod.add_linear_constraint(ub=1.0, name="c") + c.set_coefficient(x, 1.0) + c.set_coefficient(y, 1.0) + d = mod.add_linear_constraint(ub=1.0, name="d") + d.set_coefficient(y, 1.0) + d.set_coefficient(z, 1.0) + model_params = model_parameters.ModelSolveParameters() + model_params.variable_values_filter = sparse_containers.SparseVectorFilter( + skip_zero_values=True + ) + model_params.dual_values_filter = sparse_containers.SparseVectorFilter( + filtered_items=[d] + ) + model_params.reduced_costs_filter = sparse_containers.SparseVectorFilter( + filtered_items=[y, z] + ) + res = solve.solve(mod, parameters.SolverType.GLOP, model_params=model_params) + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertAlmostEqual(2.0, res.termination.objective_bounds.primal_bound) + self.assertGreaterEqual(len(res.solutions), 1) + assert ( + res.solutions[0].primal_solution is not None + and res.solutions[0].dual_solution is not None + ) + self.assertAlmostEqual(2.0, res.solutions[0].primal_solution.objective_value) + # y is zero and thus filtered. + self._assert_dict_almost_equal( + {x: 1.0, z: 1.0}, res.solutions[0].primal_solution.variable_values + ) + self.assertGreaterEqual(len(res.solutions), 1) + dual = res.solutions[0].dual_solution + self.assertAlmostEqual(2.0, dual.objective_value) + # The dual was filtered by id + self.assertSetEqual({d}, set(dual.dual_values.keys())) + self.assertSetEqual({y, z}, set(dual.reduced_costs.keys())) + + def test_message_callback(self): + opt_model = model.Model() + x = opt_model.add_binary_variable(name="x") + opt_model.objective.is_maximize = True + opt_model.objective.set_linear_coefficient(x, 2.0) + + logs = io.StringIO() + res = solve.solve( + opt_model, + parameters.SolverType.GSCIP, + msg_cb=message_callback.printer_message_callback(file=logs), + ) + + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertIn("problem is solved", logs.getvalue()) + + def test_incremental_solve_init_error(self) -> None: + mod = model.Model(name="test_model") + mod.add_variable(lb=1.0, ub=1.0, name="x1") + mod.add_variable(lb=1.0, ub=1.0, name="x1") + with self.assertRaisesRegex(RuntimeError, "duplicate name*"): + solve.IncrementalSolver(mod, parameters.SolverType.GLOP) + + def test_incremental_solve_error(self) -> None: + mod = model.Model(name="test_model") + mod.add_variable(lb=1.0, ub=-1.0, name="x1") + solver = solve.IncrementalSolver(mod, parameters.SolverType.GLOP) + with self.assertRaisesRegex( + RuntimeError, "variables.*lower_bound > upper_bound" + ): + solver.solve() + + def test_incremental_solve_error_on_reject(self) -> None: + opt_model = model.Model() + x = opt_model.add_binary_variable(name="x") + opt_model.objective.set_linear_coefficient(x, 2.0) + opt_model.objective.is_maximize = True + # CP-SAT rejects all model changes and solves from scratch each time. + solver = solve.IncrementalSolver(opt_model, parameters.SolverType.CP_SAT) + + res = solver.solve( + msg_cb=message_callback.printer_message_callback(prefix="[solve 1] ") + ) + + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res.solutions), 1) + assert res.solutions[0].primal_solution is not None + self.assertAlmostEqual(2.0, res.solutions[0].primal_solution.objective_value) + self._assert_dict_almost_equal( + {x: 1.0}, res.solutions[0].primal_solution.variable_values + ) + + opt_model.add_binary_variable(name="x") + with self.assertRaisesRegex(RuntimeError, "duplicate name*"): + solver.solve( + msg_cb=message_callback.printer_message_callback(prefix="[solve 2] ") + ) + + def test_incremental_lp(self) -> None: + opt_model = model.Model() + x = opt_model.add_variable(lb=0, ub=1, name="x") + opt_model.objective.set_linear_coefficient(x, 2.0) + opt_model.objective.is_maximize = True + solver = solve.IncrementalSolver(opt_model, parameters.SolverType.GLOP) + params = parameters.SolveParameters() + params.enable_output = True + res = solver.solve(params=params) + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res.solutions), 1) + assert res.solutions[0].primal_solution is not None + self.assertAlmostEqual(2.0, res.solutions[0].primal_solution.objective_value) + self._assert_dict_almost_equal( + {x: 1.0}, res.solutions[0].primal_solution.variable_values + ) + + x.upper_bound = 3.0 + res2 = solver.solve(params=params) + self.assertEqual( + res2.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res2.solutions), 1) + self.assertAlmostEqual(6.0, res2.solutions[0].primal_solution.objective_value) + self._assert_dict_almost_equal( + {x: 3.0}, res2.solutions[0].primal_solution.variable_values + ) + + def test_incremental_mip(self) -> None: + opt_model = model.Model() + x = opt_model.add_binary_variable(name="x") + y = opt_model.add_binary_variable(name="y") + c = opt_model.add_linear_constraint(ub=1.0, name="c") + c.set_coefficient(x, 1.0) + c.set_coefficient(y, 1.0) + opt_model.objective.set_linear_coefficient(x, 2.0) + opt_model.objective.set_linear_coefficient(y, 3.0) + opt_model.objective.is_maximize = True + solver = solve.IncrementalSolver(opt_model, parameters.SolverType.GSCIP) + params = parameters.SolveParameters() + params.enable_output = True + res = solver.solve(params=params) + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res.solutions), 1) + assert res.solutions[0].primal_solution is not None + self.assertAlmostEqual(3.0, res.solutions[0].primal_solution.objective_value) + self._assert_dict_almost_equal( + {x: 0.0, y: 1.0}, res.solutions[0].primal_solution.variable_values + ) + + c.upper_bound = 2.0 + res2 = solver.solve(params=params) + self.assertEqual( + res2.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res2.solutions), 1) + assert res2.solutions[0].primal_solution is not None + self.assertAlmostEqual(5.0, res2.solutions[0].primal_solution.objective_value) + self._assert_dict_almost_equal( + {x: 1.0, y: 1.0}, res2.solutions[0].primal_solution.variable_values + ) + + def test_incremental_mip_with_message_cb(self) -> None: + opt_model = model.Model() + x = opt_model.add_binary_variable(name="x") + y = opt_model.add_binary_variable(name="y") + c = opt_model.add_linear_constraint(ub=1.0, name="c") + c.set_coefficient(x, 1.0) + c.set_coefficient(y, 1.0) + opt_model.objective.set_linear_coefficient(x, 2.0) + opt_model.objective.set_linear_coefficient(y, 3.0) + opt_model.objective.is_maximize = True + solver = solve.IncrementalSolver(opt_model, parameters.SolverType.GSCIP) + params = parameters.SolveParameters() + params.enable_output = True + + logs = io.StringIO() + res = solver.solve( + params=params, + msg_cb=message_callback.printer_message_callback(file=logs), + ) + + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res.solutions), 1) + assert res.solutions[0].primal_solution is not None + self.assertAlmostEqual(3.0, res.solutions[0].primal_solution.objective_value) + self._assert_dict_almost_equal( + {x: 0.0, y: 1.0}, res.solutions[0].primal_solution.variable_values + ) + self.assertNotIn(_SCIP_LOG_SOLVE_WAS_INCREMENTAL, logs.getvalue()) + + c.upper_bound = 2.0 + + logs = io.StringIO() + res2 = solver.solve( + params=params, + msg_cb=message_callback.printer_message_callback(file=logs), + ) + + self.assertEqual( + res2.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res2.solutions), 1) + assert res2.solutions[0].primal_solution is not None + self.assertAlmostEqual(5.0, res2.solutions[0].primal_solution.objective_value) + self._assert_dict_almost_equal( + {x: 1.0, y: 1.0}, res2.solutions[0].primal_solution.variable_values + ) + self.assertIn(_SCIP_LOG_SOLVE_WAS_INCREMENTAL, logs.getvalue()) + + def test_incremental_solve_rejected(self) -> None: + opt_model = model.Model() + x = opt_model.add_binary_variable(name="x") + opt_model.objective.set_linear_coefficient(x, 2.0) + opt_model.objective.is_maximize = True + # CP-SAT rejects all model changes and solves from scratch each time. + solver = solve.IncrementalSolver(opt_model, parameters.SolverType.CP_SAT) + + res = solver.solve( + msg_cb=message_callback.printer_message_callback(prefix="[solve 1] ") + ) + + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res.solutions), 1) + assert res.solutions[0].primal_solution is not None + self.assertAlmostEqual(2.0, res.solutions[0].primal_solution.objective_value) + self._assert_dict_almost_equal( + {x: 1.0}, res.solutions[0].primal_solution.variable_values + ) + + x.upper_bound = 3.0 + + res2 = solver.solve( + msg_cb=message_callback.printer_message_callback(prefix="[solve 2] ") + ) + + self.assertEqual( + res2.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res2.solutions), 1) + assert res2.solutions[0].primal_solution is not None + self.assertAlmostEqual(6.0, res2.solutions[0].primal_solution.objective_value) + self._assert_dict_almost_equal( + { + x: 3.0, + }, + res2.solutions[0].primal_solution.variable_values, + ) + + def test_multiple_incremental_lps(self) -> None: + opt_model = model.Model() + x = opt_model.add_variable(lb=0, ub=1, name="x") + opt_model.objective.set_linear_coefficient(x, 2.0) + opt_model.objective.is_maximize = True + solver = solve.IncrementalSolver(opt_model, parameters.SolverType.GLOP) + params = parameters.SolveParameters() + params.presolve = parameters.Emphasis.OFF + params.enable_output = True + for ub in [2.0, 3.0, 4.0, 5.0]: + x.upper_bound = ub + res = solver.solve(params=params) + self.assertEqual( + res.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res.termination, + ) + self.assertGreaterEqual(len(res.solutions), 1) + assert res.solutions[0].primal_solution is not None + self.assertAlmostEqual( + 2.0 * ub, res.solutions[0].primal_solution.objective_value + ) + self._assert_dict_almost_equal( + {x: ub}, res.solutions[0].primal_solution.variable_values + ) + + def test_incremental_solver_delete(self) -> None: + opt_model = model.Model() + start_num_solver = core_solver.debug_num_solver() + solver = solve.IncrementalSolver(opt_model, parameters.SolverType.GLOP) + self.assertEqual(core_solver.debug_num_solver(), start_num_solver + 1) + del solver + self.assertEqual(core_solver.debug_num_solver(), start_num_solver) + + def test_incremental_solver_close(self) -> None: + opt_model = model.Model() + start_num_solver = core_solver.debug_num_solver() + solver = solve.IncrementalSolver(opt_model, parameters.SolverType.GLOP) + self.assertEqual(core_solver.debug_num_solver(), start_num_solver + 1) + solver.close() + self.assertEqual(core_solver.debug_num_solver(), start_num_solver) + with self.assertRaisesRegex(RuntimeError, "closed"): + solver.solve() + + def test_incremental_solver_close_twice(self) -> None: + opt_model = model.Model() + start_num_solver = core_solver.debug_num_solver() + solver = solve.IncrementalSolver(opt_model, parameters.SolverType.GLOP) + self.assertEqual(core_solver.debug_num_solver(), start_num_solver + 1) + solver.close() + solver.close() + self.assertEqual(core_solver.debug_num_solver(), start_num_solver) + + def test_incremental_solver_context_manager(self) -> None: + opt_model = model.Model() + start_num_solver = core_solver.debug_num_solver() + with solve.IncrementalSolver(opt_model, parameters.SolverType.GLOP) as solver: + self.assertEqual(core_solver.debug_num_solver(), start_num_solver + 1) + res1 = solver.solve() + self.assertEqual( + res1.termination.reason, + result.TerminationReason.OPTIMAL, + msg=res1.termination, + ) + res2 = solver.solve() + self.assertEqual( + res2.termination.reason, result.TerminationReason.OPTIMAL, msg=res2 + ) + + self.assertEqual(core_solver.debug_num_solver(), start_num_solver) + with self.assertRaisesRegex(RuntimeError, "closed"): + solver.solve() + + def test_incremental_solver_context_manager_exception(self) -> None: + """Tests that exceptions raised in the context manager are not lost.""" + opt_model = model.Model() + with self.assertRaisesRegex(NotImplementedError, "some message"): + with solve.IncrementalSolver(opt_model, parameters.SolverType.GLOP): + raise NotImplementedError("some message") + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/sparse_containers.py b/ortools/math_opt/python/sparse_containers.py new file mode 100644 index 0000000000..24c24b4fab --- /dev/null +++ b/ortools/math_opt/python/sparse_containers.py @@ -0,0 +1,124 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sparse vectors and matrices using variables and constraints from Model. + +Analogous to sparse_containers.proto, with bidirectional conversion. +""" +from typing import Dict, FrozenSet, Generic, Iterable, Mapping, Optional, Set, TypeVar + +from ortools.math_opt import sparse_containers_pb2 +from ortools.math_opt.python import model + + +VarOrConstraintType = TypeVar( + "VarOrConstraintType", model.Variable, model.LinearConstraint +) + + +def to_sparse_double_vector_proto( + terms: Mapping[VarOrConstraintType, float] +) -> sparse_containers_pb2.SparseDoubleVectorProto: + """Converts a sparse vector from proto to dict representation.""" + result = sparse_containers_pb2.SparseDoubleVectorProto() + if terms: + id_and_values = [(key.id, value) for (key, value) in terms.items()] + id_and_values.sort() + ids, values = zip(*id_and_values) + result.ids[:] = ids + result.values[:] = values + return result + + +def to_sparse_int32_vector_proto( + terms: Mapping[VarOrConstraintType, int] +) -> sparse_containers_pb2.SparseInt32VectorProto: + """Converts a sparse vector from proto to dict representation.""" + result = sparse_containers_pb2.SparseInt32VectorProto() + if terms: + id_and_values = [(key.id, value) for (key, value) in terms.items()] + id_and_values.sort() + ids, values = zip(*id_and_values) + result.ids[:] = ids + result.values[:] = values + return result + + +def parse_variable_map( + proto: sparse_containers_pb2.SparseDoubleVectorProto, mod: model.Model +) -> Dict[model.Variable, float]: + """Converts a sparse vector of variables from proto to dict representation.""" + result = {} + for index, var_id in enumerate(proto.ids): + result[mod.get_variable(var_id)] = proto.values[index] + return result + + +def parse_linear_constraint_map( + proto: sparse_containers_pb2.SparseDoubleVectorProto, mod: model.Model +) -> Dict[model.LinearConstraint, float]: + """Converts a sparse vector of linear constraints from proto to dict representation.""" + result = {} + for index, lin_con_id in enumerate(proto.ids): + result[mod.get_linear_constraint(lin_con_id)] = proto.values[index] + return result + + +class SparseVectorFilter(Generic[VarOrConstraintType]): + """Restricts the variables or constraints returned in a sparse vector. + + The default behavior is to return entries for all variables/constraints. + + E.g. when requesting the solution to an optimization problem, use this class + to restrict the variables that values are returned for. + + Attributes: + skip_zero_values: Do not include key value pairs with value zero. + filtered_items: If not None, include only key value pairs these keys. Note + that the empty set is different (don't return any keys) from None (return + all keys). + """ + + def __init__( + self, + *, + skip_zero_values: bool = False, + filtered_items: Optional[Iterable[VarOrConstraintType]] = None, + ): + self._skip_zero_values: bool = skip_zero_values + self._filtered_items: Optional[Set[VarOrConstraintType]] = ( + None if filtered_items is None else frozenset(filtered_items) + ) # pytype: disable=annotation-type-mismatch # attribute-variable-annotations + + @property + def skip_zero_values(self) -> bool: + return self._skip_zero_values + + @property + def filtered_items(self) -> Optional[FrozenSet[VarOrConstraintType]]: + return ( + self._filtered_items + ) # pytype: disable=bad-return-type # attribute-variable-annotations + + def to_proto(self): + """Returns an equivalent proto representation.""" + result = sparse_containers_pb2.SparseVectorFilterProto() + result.skip_zero_values = self._skip_zero_values + if self._filtered_items is not None: + result.filter_by_ids = True + result.filtered_ids[:] = sorted(t.id for t in self._filtered_items) + return result + + +VariableFilter = SparseVectorFilter[model.Variable] +LinearConstraintFilter = SparseVectorFilter[model.LinearConstraint] diff --git a/ortools/math_opt/python/sparse_containers_test.py b/ortools/math_opt/python/sparse_containers_test.py new file mode 100644 index 0000000000..69f9ced98d --- /dev/null +++ b/ortools/math_opt/python/sparse_containers_test.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from ortools.math_opt import sparse_containers_pb2 +from ortools.math_opt.python import model +from ortools.math_opt.python import sparse_containers +from ortools.math_opt.python.testing import compare_proto + + +class SparseDoubleVectorTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_to_proto_empty(self) -> None: + actual = sparse_containers.to_sparse_double_vector_proto({}) + self.assert_protos_equiv( + actual, sparse_containers_pb2.SparseDoubleVectorProto() + ) + + def test_to_proto_vars(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + self.assert_protos_equiv( + sparse_containers.to_sparse_double_vector_proto({z: 4.0, x: 1.0}), + sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 2], values=[1.0, 4.0] + ), + ) + + def test_to_proto_lin_cons(self) -> None: + mod = model.Model(name="test_model") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + self.assert_protos_equiv( + sparse_containers.to_sparse_double_vector_proto({c: 4.0, d: 1.0}), + sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 1], values=[4.0, 1.0] + ), + ) + + def test_parse_var_map(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + actual = sparse_containers.parse_variable_map( + sparse_containers_pb2.SparseDoubleVectorProto( + ids=[0, 2], values=[1.0, 4.0] + ), + mod, + ) + self.assertDictEqual(actual, {x: 1.0, z: 4.0}) + + def test_parse_var_map_empty(self) -> None: + mod = model.Model(name="test_model") + mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + mod.add_binary_variable(name="z") + actual = sparse_containers.parse_variable_map( + sparse_containers_pb2.SparseDoubleVectorProto(), mod + ) + self.assertDictEqual(actual, {}) + + def test_parse_lin_con_map(self) -> None: + mod = model.Model(name="test_model") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + e = mod.add_linear_constraint(lb=0.0, ub=1.0, name="e") + actual = sparse_containers.parse_linear_constraint_map( + sparse_containers_pb2.SparseDoubleVectorProto( + ids=[1, 2], values=[5.0, 4.0] + ), + mod, + ) + self.assertDictEqual(actual, {d: 5.0, e: 4.0}) + + def test_parse_lin_con_map_empty(self) -> None: + mod = model.Model(name="test_model") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="e") + actual = sparse_containers.parse_linear_constraint_map( + sparse_containers_pb2.SparseDoubleVectorProto(), mod + ) + self.assertDictEqual(actual, {}) + + +class SparseInt32VectorTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_to_proto_empty(self) -> None: + self.assert_protos_equiv( + sparse_containers.to_sparse_int32_vector_proto({}), + sparse_containers_pb2.SparseInt32VectorProto(), + ) + + def test_to_proto_vars(self) -> None: + mod = model.Model(name="test_model") + x = mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + self.assert_protos_equiv( + sparse_containers.to_sparse_int32_vector_proto({z: 4, x: 1}), + sparse_containers_pb2.SparseInt32VectorProto(ids=[0, 2], values=[1, 4]), + ) + + def test_to_proto_lin_cons(self) -> None: + mod = model.Model(name="test_model") + c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + self.assert_protos_equiv( + sparse_containers.to_sparse_int32_vector_proto({c: 4, d: 1}), + sparse_containers_pb2.SparseInt32VectorProto(ids=[0, 1], values=[4, 1]), + ) + + +class SparseVectorFilterTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): + def test_is_none(self) -> None: + f = sparse_containers.SparseVectorFilter(skip_zero_values=True) + self.assertTrue(f.skip_zero_values) + self.assertIsNone(f.filtered_items) + expected_proto = sparse_containers_pb2.SparseVectorFilterProto( + skip_zero_values=True + ) + self.assert_protos_equiv(f.to_proto(), expected_proto) + + def test_ids_is_empty(self) -> None: + f = sparse_containers.SparseVectorFilter(filtered_items=[]) + self.assertFalse(f.skip_zero_values) + self.assertEmpty(f.filtered_items) + expected_proto = sparse_containers_pb2.SparseVectorFilterProto( + filter_by_ids=True + ) + self.assert_protos_equiv(f.to_proto(), expected_proto) + + def test_ids_are_lin_cons(self) -> None: + mod = model.Model(name="test_model") + mod.add_linear_constraint(lb=0.0, ub=1.0, name="c") + d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d") + f = sparse_containers.LinearConstraintFilter( + skip_zero_values=True, filtered_items=[d] + ) + self.assertTrue(f.skip_zero_values) + self.assertSetEqual(f.filtered_items, {d}) + expected_proto = sparse_containers_pb2.SparseVectorFilterProto( + skip_zero_values=True, filter_by_ids=True, filtered_ids=[1] + ) + self.assert_protos_equiv(f.to_proto(), expected_proto) + + def test_ids_are_vars(self) -> None: + mod = model.Model(name="test_model") + w = mod.add_binary_variable(name="w") + x = mod.add_binary_variable(name="x") + mod.add_binary_variable(name="y") + z = mod.add_binary_variable(name="z") + f = sparse_containers.VariableFilter(filtered_items=(z, w, x)) + self.assertFalse(f.skip_zero_values) + self.assertSetEqual(f.filtered_items, {w, x, z}) + expected_proto = sparse_containers_pb2.SparseVectorFilterProto( + filter_by_ids=True, filtered_ids=[0, 1, 3] + ) + self.assert_protos_equiv(f.to_proto(), expected_proto) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/python/statistics.py b/ortools/math_opt/python/statistics.py new file mode 100644 index 0000000000..a4331cf65d --- /dev/null +++ b/ortools/math_opt/python/statistics.py @@ -0,0 +1,172 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Statistics about MIP/LP models.""" + +import dataclasses +import io +import math +from typing import Iterable, Optional + +from ortools.math_opt.python import model + + +@dataclasses.dataclass(frozen=True) +class Range: + """A close range of values [min, max]. + + Attributes: + minimum: The minimum value. + maximum: The maximum value. + """ + + minimum: float + maximum: float + + +def merge_optional_ranges( + lhs: Optional[Range], rhs: Optional[Range] +) -> Optional[Range]: + """Merges the two optional ranges. + + Args: + lhs: The left hand side range. + rhs: The right hand side range. + + Returns: + A merged range (None if both lhs and rhs are None). + """ + if lhs is None: + return rhs + if rhs is None: + return lhs + return Range( + minimum=min(lhs.minimum, rhs.minimum), + maximum=max(lhs.maximum, rhs.maximum), + ) + + +def absolute_finite_non_zeros_range(values: Iterable[float]) -> Optional[Range]: + """Returns the range of the absolute values of the finite non-zeros. + + Args: + values: An iterable object of float values. + + Returns: + The range of the absolute values of the finite non-zeros, None if no such + value is found. + """ + minimum: Optional[float] = None + maximum: Optional[float] = None + for v in values: + v = abs(v) + if math.isinf(v) or v == 0.0: + continue + if minimum is None: + minimum = v + maximum = v + else: + minimum = min(minimum, v) + maximum = max(maximum, v) + + assert (maximum is None) == (minimum is None), (minimum, maximum) + + if minimum is None: + return None + return Range(minimum=minimum, maximum=maximum) + + +@dataclasses.dataclass(frozen=True) +class ModelRanges: + """The ranges of the absolute values of the finite non-zero values in the model. + + Each range is optional since there may be no finite non-zero values + (e.g. empty model, empty objective, all variables unbounded, ...). + + Attributes: + objective_terms: The linear and quadratic objective terms (not including the + offset). + variable_bounds: The variables' lower and upper bounds. + linear_constraint_bounds: The linear constraints' lower and upper bounds. + linear_constraint_coefficients: The coefficients of the variables in linear + constraints. + """ + + objective_terms: Optional[Range] + variable_bounds: Optional[Range] + linear_constraint_bounds: Optional[Range] + linear_constraint_coefficients: Optional[Range] + + def __str__(self) -> str: + """Prints the ranges in scientific format with 2 digits (i.e. + + f'{x:.2e}'). + + It returns a multi-line table list of ranges. The last line does NOT end + with a new line. + + Returns: + The ranges in multiline string. + """ + buf = io.StringIO() + + def print_range(prefix: str, value: Optional[Range]) -> None: + buf.write(prefix) + if value is None: + buf.write("no finite values") + return + # Numbers are printed in scientific notation with a precision of 2. Since + # they are expected to be positive we can ignore the optional leading + # minus sign. We thus expects `d.dde[+-]dd(d)?` (the exponent is at least + # 2 digits but double can require 3 digits, with max +308 and min + # -308). Thus we can use a width of 9 to align the ranges properly. + buf.write(f"[{value.minimum:<9.2e}, {value.maximum:<9.2e}]") + + print_range("Objective terms : ", self.objective_terms) + print_range("\nVariable bounds : ", self.variable_bounds) + print_range("\nLinear constraints bounds : ", self.linear_constraint_bounds) + print_range( + "\nLinear constraints coeffs : ", self.linear_constraint_coefficients + ) + return buf.getvalue() + + +def compute_model_ranges(mdl: model.Model) -> ModelRanges: + """Returns the ranges of the finite non-zero values in the given model. + + Args: + mdl: The input model. + + Returns: + The ranges of the finite non-zero values in the model. + """ + return ModelRanges( + objective_terms=absolute_finite_non_zeros_range( + term.coefficient for term in mdl.objective.linear_terms() + ), + variable_bounds=merge_optional_ranges( + absolute_finite_non_zeros_range(v.lower_bound for v in mdl.variables()), + absolute_finite_non_zeros_range(v.upper_bound for v in mdl.variables()), + ), + linear_constraint_bounds=merge_optional_ranges( + absolute_finite_non_zeros_range( + c.lower_bound for c in mdl.linear_constraints() + ), + absolute_finite_non_zeros_range( + c.upper_bound for c in mdl.linear_constraints() + ), + ), + linear_constraint_coefficients=absolute_finite_non_zeros_range( + e.coefficient for e in mdl.linear_constraint_matrix_entries() + ), + ) diff --git a/ortools/math_opt/python/statistics_test.py b/ortools/math_opt/python/statistics_test.py new file mode 100644 index 0000000000..ea1a399127 --- /dev/null +++ b/ortools/math_opt/python/statistics_test.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for statistics.""" + +import math + +import unittest +from ortools.math_opt.python import model +from ortools.math_opt.python import statistics + + +class RangeTest(unittest.TestCase): + def test_merge_optional_ranges(self) -> None: + self.assertIsNone(statistics.merge_optional_ranges(None, None)) + r = statistics.Range(1.0, 3.0) + self.assertEqual(statistics.merge_optional_ranges(r, None), r) + self.assertEqual(statistics.merge_optional_ranges(None, r), r) + # We also test that, since Range is a frozen class, we return the non-None + # input when only one input is not None. + self.assertIs(statistics.merge_optional_ranges(r, None), r) + self.assertIs(statistics.merge_optional_ranges(None, r), r) + self.assertEqual( + statistics.merge_optional_ranges( + statistics.Range(1.0, 3.0), statistics.Range(-2.0, 2.0) + ), + statistics.Range(-2.0, 3.0), + ) + + def test_absolute_finite_non_zeros_range(self) -> None: + self.assertIsNone(statistics.absolute_finite_non_zeros_range(())) + self.assertIsNone( + statistics.absolute_finite_non_zeros_range((math.inf, 0.0, -0.0, -math.inf)) + ) + self.assertEqual( + statistics.absolute_finite_non_zeros_range( + (math.inf, -5.0e2, 0.0, 1.5e-3, -0.0, -math.inf, 1.25e-6, 3.0e2) + ), + statistics.Range(minimum=1.25e-6, maximum=5.0e2), + ) + + +class ModelRangesTest(unittest.TestCase): + def test_printing(self) -> None: + self.assertMultiLineEqual( + str( + statistics.ModelRanges( + objective_terms=None, + variable_bounds=None, + linear_constraint_bounds=None, + linear_constraint_coefficients=None, + ) + ), + "Objective terms : no finite values\n" + "Variable bounds : no finite values\n" + "Linear constraints bounds : no finite values\n" + "Linear constraints coeffs : no finite values", + ) + + self.assertMultiLineEqual( + str( + statistics.ModelRanges( + objective_terms=statistics.Range(2.12345e-99, 1.12345e3), + variable_bounds=statistics.Range(9.12345e-2, 1.12345e2), + linear_constraint_bounds=statistics.Range(2.12345e6, 5.12345e99), + linear_constraint_coefficients=statistics.Range(0.0, 0.0), + ) + ), + "Objective terms : [2.12e-99 , 1.12e+03 ]\n" + "Variable bounds : [9.12e-02 , 1.12e+02 ]\n" + "Linear constraints bounds : [2.12e+06 , 5.12e+99 ]\n" + "Linear constraints coeffs : [0.00e+00 , 0.00e+00 ]", + ) + + self.assertMultiLineEqual( + str( + statistics.ModelRanges( + objective_terms=statistics.Range(2.12345e-1, 1.12345e3), + variable_bounds=statistics.Range(9.12345e-2, 1.12345e2), + linear_constraint_bounds=statistics.Range(2.12345e6, 5.12345e99), + linear_constraint_coefficients=statistics.Range(0.0, 1.0e100), + ) + ), + "Objective terms : [2.12e-01 , 1.12e+03 ]\n" + "Variable bounds : [9.12e-02 , 1.12e+02 ]\n" + "Linear constraints bounds : [2.12e+06 , 5.12e+99 ]\n" + "Linear constraints coeffs : [0.00e+00 , 1.00e+100]", + ) + + self.assertMultiLineEqual( + str( + statistics.ModelRanges( + objective_terms=statistics.Range(2.12345e-100, 1.12345e3), + variable_bounds=statistics.Range(9.12345e-2, 1.12345e2), + linear_constraint_bounds=statistics.Range(2.12345e6, 5.12345e99), + linear_constraint_coefficients=statistics.Range(0.0, 0.0), + ) + ), + "Objective terms : [2.12e-100, 1.12e+03 ]\n" + "Variable bounds : [9.12e-02 , 1.12e+02 ]\n" + "Linear constraints bounds : [2.12e+06 , 5.12e+99 ]\n" + "Linear constraints coeffs : [0.00e+00 , 0.00e+00 ]", + ) + + self.assertMultiLineEqual( + str( + statistics.ModelRanges( + objective_terms=statistics.Range(2.12345e-100, 1.12345e3), + variable_bounds=statistics.Range(9.12345e-2, 1.12345e2), + linear_constraint_bounds=statistics.Range(2.12345e6, 5.12345e99), + linear_constraint_coefficients=statistics.Range(0.0, 1.0e100), + ) + ), + "Objective terms : [2.12e-100, 1.12e+03 ]\n" + "Variable bounds : [9.12e-02 , 1.12e+02 ]\n" + "Linear constraints bounds : [2.12e+06 , 5.12e+99 ]\n" + "Linear constraints coeffs : [0.00e+00 , 1.00e+100]", + ) + + +class ComputeModelRangesTest(unittest.TestCase): + def test_empty(self) -> None: + mdl = model.Model(name="model") + self.assertEqual( + statistics.compute_model_ranges(mdl), + statistics.ModelRanges( + objective_terms=None, + variable_bounds=None, + linear_constraint_bounds=None, + linear_constraint_coefficients=None, + ), + ) + + def test_only_zero_and_infinite_values(self) -> None: + mdl = model.Model(name="model") + mdl.add_variable(lb=0.0, ub=math.inf) + mdl.add_variable(lb=-math.inf, ub=0.0) + mdl.add_variable(lb=-math.inf, ub=math.inf) + mdl.add_linear_constraint(lb=0.0, ub=math.inf) + mdl.add_linear_constraint(lb=-math.inf, ub=0.0) + mdl.add_linear_constraint(lb=-math.inf, ub=math.inf) + + self.assertEqual( + statistics.compute_model_ranges(mdl), + statistics.ModelRanges( + objective_terms=None, + variable_bounds=None, + linear_constraint_bounds=None, + linear_constraint_coefficients=None, + ), + ) + + def test_mixed_values(self) -> None: + mdl = model.Model(name="model") + x = mdl.add_variable(lb=0.0, ub=0.0, name="x") + y = mdl.add_variable(lb=-math.inf, ub=1e-3, name="y") + mdl.add_variable(lb=-3e2, ub=math.inf, name="z") + + mdl.objective.is_maximize = False + mdl.objective.set_linear_coefficient(x, -5.0e4) + # TODO(b/225219234): add the quadratic term `1.0e-6 * z * x` when the + # support of quadratic objective is added to the Python API. + mdl.objective.set_linear_coefficient(y, 3.0) + + c = mdl.add_linear_constraint(lb=0.0, name="c") + c.set_coefficient(y, 1.25e-3) + c.set_coefficient(x, -4.5e3) + mdl.add_linear_constraint(lb=-math.inf, ub=3e4) + d = mdl.add_linear_constraint(lb=-1e-5, ub=0.0, name="d") + d.set_coefficient(y, 2.5e-3) + + self.assertEqual( + statistics.compute_model_ranges(mdl), + statistics.ModelRanges( + # TODO(b/225219234): update this to Range(1.0e-6, 5.0e4) once the + # quadratic term is added. + objective_terms=statistics.Range(3.0, 5.0e4), + variable_bounds=statistics.Range(1e-3, 3e2), + linear_constraint_bounds=statistics.Range(1e-5, 3e4), + linear_constraint_coefficients=statistics.Range(1.25e-3, 4.5e3), + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/samples/BUILD.bazel b/ortools/math_opt/samples/cpp/BUILD.bazel similarity index 95% rename from ortools/math_opt/samples/BUILD.bazel rename to ortools/math_opt/samples/cpp/BUILD.bazel index 7cfaa525f5..6b990b8c33 100644 --- a/ortools/math_opt/samples/BUILD.bazel +++ b/ortools/math_opt/samples/cpp/BUILD.bazel @@ -11,7 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -package(default_visibility = ["//visibility:public"]) +package(default_visibility = ["//ortools/math_opt:__subpackages__"]) cc_binary( name = "basic_example", @@ -57,6 +57,20 @@ cc_binary( ], ) +cc_binary( + name = "advanced_linear_programming", + srcs = ["advanced_linear_programming.cc"], + deps = [ + "//ortools/base", + "//ortools/base:status_macros", + "//ortools/math_opt/cpp:math_opt", + "//ortools/math_opt/solvers:glop_solver", + "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings", + "@com_google_absl//absl/time", + ], +) + cc_binary( name = "integer_programming", srcs = ["integer_programming.cc"], @@ -174,16 +188,15 @@ cc_binary( ) cc_binary( - name = "advanced_linear_programming", - srcs = ["advanced_linear_programming.cc"], + name = "graph_coloring", + srcs = ["graph_coloring.cc"], deps = [ "//ortools/base", "//ortools/base:status_macros", "//ortools/math_opt/cpp:math_opt", - "//ortools/math_opt/solvers:glop_solver", + "//ortools/math_opt/solvers:cp_sat_solver", + "@com_google_absl//absl/status", "@com_google_absl//absl/status:statusor", - "@com_google_absl//absl/strings", - "@com_google_absl//absl/time", ], ) @@ -205,14 +218,12 @@ cc_binary( ) cc_binary( - name = "graph_coloring", - srcs = ["graph_coloring.cc"], + name = "area_socp", + srcs = ["area_socp.cc"], deps = [ "//ortools/base", "//ortools/base:status_macros", "//ortools/math_opt/cpp:math_opt", - "//ortools/math_opt/solvers:cp_sat_solver", "@com_google_absl//absl/status", - "@com_google_absl//absl/status:statusor", ], ) diff --git a/ortools/math_opt/samples/cpp/CMakeLists.txt b/ortools/math_opt/samples/cpp/CMakeLists.txt new file mode 100644 index 0000000000..3904f481c5 --- /dev/null +++ b/ortools/math_opt/samples/cpp/CMakeLists.txt @@ -0,0 +1,40 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +if(NOT BUILD_SAMPLES) + return() +endif() + +if(BUILD_CXX_SAMPLES) + file(GLOB CXX_SRCS "*.cc") + list(FILTER CXX_SRCS EXCLUDE REGEX "area_socp.cc$") + + # conflict name + list(FILTER CXX_SRCS EXCLUDE REGEX "basic_example.cc$") + list(FILTER CXX_SRCS EXCLUDE REGEX "tsp.cc$") + list(FILTER CXX_SRCS EXCLUDE REGEX "integer_programming.cc$") + list(FILTER CXX_SRCS EXCLUDE REGEX "linear_programming.cc$") + list(FILTER CXX_SRCS EXCLUDE REGEX "linear_regression.cc$") + + + foreach(SAMPLE IN LISTS CXX_SRCS) + add_cxx_sample(${SAMPLE}) + endforeach() +endif() + +if(BUILD_PYTHON_SAMPLES) + file(GLOB PYTHON_SRCS "*.py") + foreach(SAMPLE IN LISTS PYTHON_SRCS) + add_python_sample(${SAMPLE}) + endforeach() +endif() diff --git a/ortools/math_opt/samples/advanced_linear_programming.cc b/ortools/math_opt/samples/cpp/advanced_linear_programming.cc similarity index 100% rename from ortools/math_opt/samples/advanced_linear_programming.cc rename to ortools/math_opt/samples/cpp/advanced_linear_programming.cc diff --git a/ortools/math_opt/samples/area_socp.cc b/ortools/math_opt/samples/cpp/area_socp.cc similarity index 100% rename from ortools/math_opt/samples/area_socp.cc rename to ortools/math_opt/samples/cpp/area_socp.cc diff --git a/ortools/math_opt/samples/basic_example.cc b/ortools/math_opt/samples/cpp/basic_example.cc similarity index 100% rename from ortools/math_opt/samples/basic_example.cc rename to ortools/math_opt/samples/cpp/basic_example.cc diff --git a/ortools/math_opt/samples/branch_and_bound.cc b/ortools/math_opt/samples/cpp/branch_and_bound.cc similarity index 100% rename from ortools/math_opt/samples/branch_and_bound.cc rename to ortools/math_opt/samples/cpp/branch_and_bound.cc diff --git a/ortools/math_opt/samples/branch_and_bound.h b/ortools/math_opt/samples/cpp/branch_and_bound.h similarity index 100% rename from ortools/math_opt/samples/branch_and_bound.h rename to ortools/math_opt/samples/cpp/branch_and_bound.h diff --git a/ortools/math_opt/samples/branch_and_bound_main.cc b/ortools/math_opt/samples/cpp/branch_and_bound_main.cc similarity index 100% rename from ortools/math_opt/samples/branch_and_bound_main.cc rename to ortools/math_opt/samples/cpp/branch_and_bound_main.cc diff --git a/ortools/math_opt/samples/cocktail_hour.cc b/ortools/math_opt/samples/cpp/cocktail_hour.cc similarity index 100% rename from ortools/math_opt/samples/cocktail_hour.cc rename to ortools/math_opt/samples/cpp/cocktail_hour.cc diff --git a/ortools/math_opt/samples/cutting_stock.cc b/ortools/math_opt/samples/cpp/cutting_stock.cc similarity index 100% rename from ortools/math_opt/samples/cutting_stock.cc rename to ortools/math_opt/samples/cpp/cutting_stock.cc diff --git a/ortools/math_opt/samples/facility_lp_benders.cc b/ortools/math_opt/samples/cpp/facility_lp_benders.cc similarity index 100% rename from ortools/math_opt/samples/facility_lp_benders.cc rename to ortools/math_opt/samples/cpp/facility_lp_benders.cc diff --git a/ortools/math_opt/samples/graph_coloring.cc b/ortools/math_opt/samples/cpp/graph_coloring.cc similarity index 100% rename from ortools/math_opt/samples/graph_coloring.cc rename to ortools/math_opt/samples/cpp/graph_coloring.cc diff --git a/ortools/math_opt/samples/integer_programming.cc b/ortools/math_opt/samples/cpp/integer_programming.cc similarity index 100% rename from ortools/math_opt/samples/integer_programming.cc rename to ortools/math_opt/samples/cpp/integer_programming.cc diff --git a/ortools/math_opt/samples/lagrangian_relaxation.cc b/ortools/math_opt/samples/cpp/lagrangian_relaxation.cc similarity index 100% rename from ortools/math_opt/samples/lagrangian_relaxation.cc rename to ortools/math_opt/samples/cpp/lagrangian_relaxation.cc diff --git a/ortools/math_opt/samples/linear_programming.cc b/ortools/math_opt/samples/cpp/linear_programming.cc similarity index 100% rename from ortools/math_opt/samples/linear_programming.cc rename to ortools/math_opt/samples/cpp/linear_programming.cc diff --git a/ortools/math_opt/samples/linear_regression.cc b/ortools/math_opt/samples/cpp/linear_regression.cc similarity index 100% rename from ortools/math_opt/samples/linear_regression.cc rename to ortools/math_opt/samples/cpp/linear_regression.cc diff --git a/ortools/math_opt/samples/mathopt_info.cc b/ortools/math_opt/samples/cpp/mathopt_info.cc similarity index 100% rename from ortools/math_opt/samples/mathopt_info.cc rename to ortools/math_opt/samples/cpp/mathopt_info.cc diff --git a/ortools/math_opt/samples/time_indexed_scheduling.cc b/ortools/math_opt/samples/cpp/time_indexed_scheduling.cc similarity index 100% rename from ortools/math_opt/samples/time_indexed_scheduling.cc rename to ortools/math_opt/samples/cpp/time_indexed_scheduling.cc diff --git a/ortools/math_opt/samples/tsp.cc b/ortools/math_opt/samples/cpp/tsp.cc similarity index 100% rename from ortools/math_opt/samples/tsp.cc rename to ortools/math_opt/samples/cpp/tsp.cc diff --git a/ortools/math_opt/samples/python/BUILD.bazel b/ortools/math_opt/samples/python/BUILD.bazel new file mode 100644 index 0000000000..18d3527d04 --- /dev/null +++ b/ortools/math_opt/samples/python/BUILD.bazel @@ -0,0 +1,115 @@ +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@pip_deps//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_binary") + +py_binary( + name = "tsp", + srcs = ["tsp.py"], + python_version = "PY3", + srcs_version = "PY3", + deps = [ + requirement("absl-py"), + requirement("svgwrite"), + "//ortools/math_opt/python:mathopt", + ], +) + +py_binary( + name = "basic_example", + srcs = ["basic_example.py"], + python_version = "PY3", + srcs_version = "PY3", + deps = [ + requirement("absl-py"), + "//ortools/math_opt/python:mathopt", + ], +) + +py_binary( + name = "linear_programming", + srcs = ["linear_programming.py"], + python_version = "PY3", + srcs_version = "PY3", + deps = [ + requirement("absl-py"), + "//ortools/math_opt/python:mathopt", + ], +) + +py_binary( + name = "integer_programming", + srcs = ["integer_programming.py"], + python_version = "PY3", + srcs_version = "PY3", + deps = [ + requirement("absl-py"), + "//ortools/math_opt/python:mathopt", + ], +) + +py_binary( + name = "advanced_linear_programming", + srcs = ["advanced_linear_programming.py"], + python_version = "PY3", + srcs_version = "PY3", + deps = [ + requirement("absl-py"), + "//ortools/math_opt/python:mathopt", + ], +) + +py_binary( + name = "cutting_stock", + srcs = ["cutting_stock.py"], + python_version = "PY3", + srcs_version = "PY3", + deps = [ + requirement("absl-py"), + "//ortools/math_opt/python:mathopt", + ], +) + +py_binary( + name = "facility_lp_benders", + srcs = ["facility_lp_benders.py"], + python_version = "PY3", + srcs_version = "PY3", + deps = [ + requirement("absl-py"), + requirement("numpy"), + "//ortools/math_opt/python:mathopt", + ], +) + +py_binary( + name = "linear_regression", + srcs = ["linear_regression.py"], + python_version = "PY3", + srcs_version = "PY3", + deps = [ + requirement("absl-py"), + requirement("numpy"), + "//ortools/math_opt/python:mathopt", + ], +) + +py_binary( + name = "time_indexed_scheduling", + srcs = ["time_indexed_scheduling.py"], + deps = [ + requirement("absl-py"), + "//ortools/math_opt/python:mathopt", + ], +) diff --git a/ortools/math_opt/samples/python/advanced_linear_programming.py b/ortools/math_opt/samples/python/advanced_linear_programming.py new file mode 100644 index 0000000000..cabae415e4 --- /dev/null +++ b/ortools/math_opt/samples/python/advanced_linear_programming.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Advanced linear programming example.""" + +from typing import Sequence + +from absl import app + +from ortools.math_opt.python import mathopt + + +# Model and solve the problem: +# max 10 * x0 + 6 * x1 + 4 * x2 +# s.t. 10 * x0 + 4 * x1 + 5 * x2 <= 600 +# 2 * x0 + 2 * x1 + 6 * x2 <= 300 +# x0 + x1 + x2 <= 100 +# x0 in [0, infinity) +# x1 in [0, infinity) +# x2 in [0, infinity) +# +def main(argv: Sequence[str]) -> None: + del argv # Unused. + + model = mathopt.Model(name="Advanced linear programming example") + + # Variables + x = [model.add_variable(lb=0.0, name=f"x{j}") for j in range(3)] + + # Constraints + constraints = [ + model.add_linear_constraint(10 * x[0] + 4 * x[1] + 5 * x[2] <= 600, name="c1"), + model.add_linear_constraint(2 * x[0] + 2 * x[1] + 6 * x[2] <= 300, name="c2"), + model.add_linear_constraint(sum(x) <= 100, name="c3"), + ] + + # Objective + model.maximize(10 * x[0] + 6 * x[1] + 4 * x[2]) + + # May raise a RuntimeError on invalid input or internal solver errors. + result = mathopt.solve(model, mathopt.SolverType.GLOP) + + if result.termination.reason != mathopt.TerminationReason.OPTIMAL: + raise RuntimeError(f"model failed to solve to optimality: {result.termination}") + + print(f"Problem solved in {result.solve_time()}") + print(f"Objective value: {result.objective_value()}") + variable_values = [result.variable_values()[v] for v in x] + print(f"Variable values: {variable_values}") + + if not result.has_dual_feasible_solution(): + # MathOpt does not require solvers to return a dual solution on optimal, + # but most LP solvers always will, see go/mathopt-solver-contracts for + # details. + raise RuntimeError("no dual solution was returned on optimal") + + dual_values = [result.dual_values()[c] for c in constraints] + print(f"Constraint duals: {dual_values}") + reduced_costs = [result.reduced_costs()[v] for v in x] + print(f"Reduced costs: {reduced_costs}") + + if not result.has_basis(): + # MathOpt does not require solvers to return a basis on optimal, but most + # Simplex LP solvers (like Glop) always will, see + # go/mathopt-solver-contracts for detail + raise RuntimeError("no basis was returned on optimal") + + constraint_status = [result.constraint_status()[c] for c in constraints] + print(f"Constraint basis status: {constraint_status}") + variable_status = [result.variable_status()[v] for v in x] + print(f"Variable basis status: {variable_status}") + + +if __name__ == "__main__": + app.run(main) diff --git a/ortools/math_opt/samples/python/advanced_linear_programming_test.py b/ortools/math_opt/samples/python/advanced_linear_programming_test.py new file mode 100644 index 0000000000..b06c5ee808 --- /dev/null +++ b/ortools/math_opt/samples/python/advanced_linear_programming_test.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from ortools.math_opt.examples import log_scraping +from ortools.math_opt.testing import binary_testing + + +class LinearProgrammingTest( + binary_testing.BinaryAssertions, + log_scraping.LogScraping, + unittest.TestCase, +): + def test_linear_programming_example(self) -> None: + result = self.assert_binary_succeeds( + "ortools/math_opt/examples/python/advanced_linear_programming" + ) + objective_value = self.assert_has_line_with_prefixed_number( + "Objective value:", result.stdout + ) + self.assertAlmostEqual(objective_value, 733 + 1 / 3, delta=1e-2) + self.assertIn("Constraint duals", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/samples/python/basic_example.py b/ortools/math_opt/samples/python/basic_example.py new file mode 100644 index 0000000000..1cec8b05ae --- /dev/null +++ b/ortools/math_opt/samples/python/basic_example.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Testing correctness of the code snippets in the comments of model.py.""" +from typing import Sequence + +from absl import app + +from ortools.math_opt.python import mathopt + + +# Model the problem: +# max 2.0 * x + y +# s.t. x + y <= 1.5 +# x in {0.0, 1.0} +# y in [0.0, 2.5] +# +def main(argv: Sequence[str]) -> None: + del argv # Unused. + + model = mathopt.Model(name="my_model") + x = model.add_binary_variable(name="x") + y = model.add_variable(lb=0.0, ub=2.5, name="y") + # We can directly use linear combinations of variables ... + model.add_linear_constraint(x + y <= 1.5, name="c") + # ... or build them incrementally. + objective_expression = 0 + objective_expression += 2 * x + objective_expression += y + model.maximize(objective_expression) + + # May raise a RuntimeError on invalid input or internal solver errors. + result = mathopt.solve(model, mathopt.SolverType.GSCIP) + + if result.termination.reason not in ( + mathopt.TerminationReason.OPTIMAL, + mathopt.TerminationReason.FEASIBLE, + ): + raise RuntimeError(f"model failed to solve: {result.termination}") + + print(f"Objective value: {result.objective_value()}") + print(f"Value for variable x: {result.variable_values()[x]}") + + +if __name__ == "__main__": + app.run(main) diff --git a/ortools/math_opt/samples/python/basic_example_test.py b/ortools/math_opt/samples/python/basic_example_test.py new file mode 100644 index 0000000000..0db8e52bdc --- /dev/null +++ b/ortools/math_opt/samples/python/basic_example_test.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from ortools.math_opt.examples import log_scraping +from ortools.math_opt.testing import binary_testing + + +class BasicExampleTest( + binary_testing.BinaryAssertions, + log_scraping.LogScraping, + unittest.TestCase, +): + def test_basic_example(self) -> None: + result = self.assert_binary_succeeds( + "ortools/math_opt/examples/python/basic_example" + ) + objective_value = self.assert_has_line_with_prefixed_number( + "Objective value:", result.stdout + ) + self.assertAlmostEqual(objective_value, 2.5, delta=1e-2) + x_value = self.assert_has_line_with_prefixed_number( + "Value for variable x:", result.stdout + ) + self.assertAlmostEqual(x_value, 1.0, delta=1e-2) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/samples/python/cutting_stock.py b/ortools/math_opt/samples/python/cutting_stock.py new file mode 100644 index 0000000000..c7b5f300e3 --- /dev/null +++ b/ortools/math_opt/samples/python/cutting_stock.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Solve the cutting stock problem by column generation. + +The Cutting Stock problem is as follows. You begin with unlimited boards, all +of the same length. You are also given a list of smaller pieces to cut out, +each with a length and a demanded quantity. You want to cut out all these +pieces using as few of your starting boards as possible. + +E.g. you begin with boards that are 20 feet long, and you must cut out 3 +pieces that are 6 feet long and 5 pieces that are 8 feet long. An optimal +solution is: + [(6,), (8, 8) (8, 8), (6, 6, 8)] +(We cut a 6 foot piece from the first board, two 8 foot pieces from +the second board, and so on.) + +This example approximately solves the problem with a column generation +heuristic. The leader problem is a set cover problem, and the worker is a +knapsack problem. We alternate between solving the LP relaxation of the +leader incrementally, and solving the worker to generate new a configuration +(a column) for the leader. When the worker can no longer find a column +improving the LP cost, we convert the leader problem to a MIP and solve +again. We now give precise statements of the leader and worker. + +Problem data: + * l_i: the length of each piece we need to cut out. + * d_i: how many copies each piece we need. + * L: the length of our initial boards. + * q_ci: for configuration c, the quantity of piece i produced. + +Leader problem variables: + * x_c: how many copies of configuration c to produce. + +Leader problem formulation: + min sum_c x_c + s.t. sum_c q_ci * x_c = d_i for all i + x_c >= 0, integer for all c. + +The worker problem is to generate new configurations for the leader problem +based on the dual variables of the demand constraints in the LP relaxation. +Worker problem data: + * p_i: The "price" of piece i (dual value from leader's demand constraint) + +Worker decision variables: + * y_i: How many copies of piece i should be in the configuration. + +Worker formulation + max sum_i p_i * y_i + s.t. sum_i l_i * y_i <= L + y_i >= 0, integer for all i + +An optimal solution y* defines a new configuration c with q_ci = y_i* for all +i. If the solution has objective value <= 1, no further improvement on the LP +is possible. For additional background and proofs see: + https://people.orie.cornell.edu/shmoys/or630/notes-06/lec16.pdf +or any other reference on the "Cutting Stock Problem". + +Note: this problem is equivalent to symmetric bin packing: + https://en.wikipedia.org/wiki/Bin_packing_problem#Formal_statement +but typically in bin packing it is not assumed that you should exploit having +multiple items of the same size. +""" +import dataclasses +from typing import List, Sequence, Tuple + +from absl import app + +from ortools.math_opt.python import mathopt + + +@dataclasses.dataclass +class CuttingStockInstance: + """Data for a cutting stock instance. + + Attributes: + piece_sizes: The size of each piece with non-zero demand. Must have the same + length as piece_demands, and each size must be in [0, board_length]. + piece_demands: The demand for a given piece. Must have the same length as + piece_sizes. + board_length: The length of each board. + """ + + piece_sizes: List[int] = dataclasses.field(default_factory=list) + piece_demands: List[int] = dataclasses.field(default_factory=list) + board_length: int = 0 + + +@dataclasses.dataclass +class Configuration: + """Describes a size-configuration that can be cut out of a board. + + Attributes: + pieces: The size of each piece in the configuration. Must have the same + length as piece_demands, and the total sum of pieces (sum of piece sizes + times quantity of pieces) must not exceed the board length of the + associated cutting stock instance. + quantity: The qualtity of pieces of a given size. Must have the same length + as pieces. + """ + + pieces: List[int] = dataclasses.field(default_factory=list) + quantity: List[int] = dataclasses.field(default_factory=list) + + +@dataclasses.dataclass +class CuttingStockSolution: + """Describes a solution to a cutting stock problem. + + To be feasible, the demand for each piece type must be met by the produced + configurations + + Attributes: + configurations: The configurations used by the solution. Must have the same + length as quantity. + quantity: The number of each configuration in the solution. Must have the + same length as configurations. + objective_value: The objective value of the configuration, which is equal to + sum(quantity). + """ + + configurations: List[Configuration] = dataclasses.field(default_factory=list) + quantity: List[int] = dataclasses.field(default_factory=list) + objective_value: int = 0 + + +def best_configuration( + piece_prices: List[float], piece_sizes: List[int], board_size: int +) -> Tuple[Configuration, float]: + """Solves the worker problem. + + Solves the problem on finding the configuration (with its objective value) to + add the to model that will give the greatest improvement in the LP + relaxation. This is equivalent to a knapsack problem. + + Args: + piece_prices: The price for each piece with non-zero demand. Must have the + same length as piece_sizes. + piece_sizes: The size of each piece with non-zero demand. Must have the same + length as piece_prices, and each size must be in [0, board_length]. + board_size: The length of each board. + + Returns: + The best configuration and its cost. + + Raises: + RuntimeError: On solve errors. + """ + num_pieces = len(piece_sizes) + assert len(piece_sizes) == num_pieces + model = mathopt.Model(name="knapsack") + pieces = [ + model.add_integer_variable(lb=0, name=f"item_{i}") for i in range(num_pieces) + ] + model.maximize(sum(piece_prices[i] * pieces[i] for i in range(num_pieces))) + model.add_linear_constraint( + sum(piece_sizes[i] * pieces[i] for i in range(num_pieces)) <= board_size + ) + solve_result = mathopt.solve(model, mathopt.SolverType.CP_SAT) + if solve_result.termination.reason != mathopt.TerminationReason.OPTIMAL: + raise RuntimeError( + "Failed to solve knapsack pricing problem to " + f" optimality: {solve_result.termination}" + ) + config = Configuration() + for i in range(num_pieces): + use = round(solve_result.variable_values()[pieces[i]]) + if use > 0: + config.pieces.append(i) + config.quantity.append(use) + return config, solve_result.objective_value() + + +def solve_cutting_stock(instance: CuttingStockInstance) -> CuttingStockSolution: + """Solves the full cutting stock problem by decomposition. + + Args: + instance: A cutting stock instance. + + Returns: + A solution to the cutting stock instance. + + Raises: + RuntimeError: On solve errors. + """ + model = mathopt.Model(name="cutting_stock") + model.objective.is_maximize = False + n = len(instance.piece_sizes) + demands = instance.piece_demands + demand_met = [ + model.add_linear_constraint(lb=demands[i], ub=demands[i]) for i in range(n) + ] + + configs: List[Tuple[Configuration, mathopt.Variable]] = [] + + def add_config(config: Configuration) -> None: + v = model.add_variable(lb=0.0) + model.objective.set_linear_coefficient(v, 1) + for item, use in zip(config.pieces, config.quantity): + if use >= 1: + demand_met[item].set_coefficient(v, use) + configs.append((config, v)) + + # To ensure the leader problem is always feasible, begin a configuration for + # every item that has a single copy of the item. + for i in range(n): + add_config(Configuration(pieces=[i], quantity=[1])) + + solver = mathopt.IncrementalSolver(model, mathopt.SolverType.GLOP) + + pricing_round = 0 + while True: + solve_result = solver.solve() + if solve_result.termination.reason != mathopt.TerminationReason.OPTIMAL: + raise RuntimeError( + "Failed to solve leader LP problem to optimality at " + f"iteration {pricing_round} termination: " + f"{solve_result.termination}" + ) + if not solve_result.has_dual_feasible_solution: + # MathOpt does not require solvers to return a dual solution on optimal, + # but most LP solvers always will, see go/mathopt-solver-contracts for + # details. + raise RuntimeError( + "no dual solution was returned with optimal solution " + f"at iteration {pricing_round}" + ) + prices = [solve_result.dual_values()[d] for d in demand_met] + config, value = best_configuration( + prices, instance.piece_sizes, instance.board_length + ) + if value < 1 + 1e-3: + # The LP relaxation is solved, we can stop adding columns. + break + add_config(config) + print( + f"round: {pricing_round}, " + f"lp objective: {solve_result.objective_value()}", + flush=True, + ) + pricing_round += 1 + print("Done adding columns, switching to MIP") + for _, var in configs: + var.integer = True + + solve_result = mathopt.solve(model, mathopt.SolverType.CP_SAT) + if solve_result.termination.reason not in ( + mathopt.TerminationReason.OPTIMAL, + mathopt.TerminationReason.FEASIBLE, + ): + raise RuntimeError( + "Failed to solve final cutting stock MIP, " + f"termination: {solve_result.termination}" + ) + + solution = CuttingStockSolution() + for config, var in configs: + use = round(solve_result.variable_values()[var]) + if use > 0: + solution.configurations.append(config) + solution.quantity.append(use) + solution.objective_value += use + return solution + + +def main(argv: Sequence[str]) -> None: + del argv # Unused. + + # Data from https://en.wikipedia.org/wiki/Cutting_stock_problem + instance = CuttingStockInstance( + board_length=5600, + piece_sizes=[ + 1380, + 1520, + 1560, + 1710, + 1820, + 1880, + 1930, + 2000, + 2050, + 2100, + 2140, + 2150, + 2200, + ], + piece_demands=[22, 25, 12, 14, 18, 18, 20, 10, 12, 14, 16, 18, 20], + ) + solution = solve_cutting_stock(instance) + print("Best known solution uses 73 rolls.") + print(f"Total rolls used in actual solution found: {solution.objective_value}") + + +if __name__ == "__main__": + app.run(main) diff --git a/ortools/math_opt/samples/python/cutting_stock_test.py b/ortools/math_opt/samples/python/cutting_stock_test.py new file mode 100644 index 0000000000..8f04507f05 --- /dev/null +++ b/ortools/math_opt/samples/python/cutting_stock_test.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from ortools.math_opt.testing import binary_testing + + +class CuttingStockTest(binary_testing.BinaryAssertions, unittest.TestCase): + def test_tsp_simple(self) -> None: + output = self.assert_binary_succeeds( + "ortools/" "math_opt/examples/python/cutting_stock" + ) + self.assertIn("actual solution found: 73", output.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/samples/python/facility_lp_benders.py b/ortools/math_opt/samples/python/facility_lp_benders.py new file mode 100644 index 0000000000..32958f0700 --- /dev/null +++ b/ortools/math_opt/samples/python/facility_lp_benders.py @@ -0,0 +1,603 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""An advanced benders decomposition example. + +We consider a network design problem where each location has a demand that +must be met by its neighboring facilities, and each facility can control +its total capacity. In this version we also require that locations cannot +use more that a specified fraction of a facilities capacity. + +Problem data: +* F: set of facilities. +* L: set of locations. +* E: subset of {(f,l) : f in F, l in L} that describes the network between + facilities and locations. +* d: demand at location (all demands are equal for simplicity). +* c: cost per unit of capacity at a facility (all facilities are have the + same cost for simplicity). +* h: cost per unit transported through an edge. +* a: fraction of a facility's capacity that can be used by each location. + +Decision variables: +* z_f: capacity at facility f in F. +* x_(f,l): flow from facility f to location l for all (f,l) in E. + +Formulation: + + min c * sum(z_f : f in F) + sum(h_e * x_e : e in E) + s.t. + x_(f,l) <= a * z_f for all (f,l) in E + sum(x_(f,l) : l such that (f,l) in E) <= z_f for all f in F + sum(x_(f,l) : f such that (f,l) in E) >= d for all l in L + x_e >= 0 for all e in E + z_f >= 0 for all f in F + +Below we solve this problem directly and using a benders decompostion +approach. +""" +import dataclasses +import math +import time +from typing import Dict, List, Sequence, Tuple + +from absl import app +from absl import flags +import numpy as np + +from ortools.math_opt.python import mathopt + +_NUM_FACILITIES = flags.DEFINE_integer("num_facilities", 3000, "Number of facilities.") +_NUM_LOCATIONS = flags.DEFINE_integer("num_locations", 50, "Number of locations.") +_EDGE_PROBABILITY = flags.DEFINE_float("edge_probability", 0.99, "Edge probability.") +_BENDERS_PRECISSION = flags.DEFINE_float( + "benders_precission", 1e-9, "Benders target precission." +) +_LOCATION_DEMAND = flags.DEFINE_float("location_demand", 1, "Client demands.") +_FACILITY_COST = flags.DEFINE_float("facility_cost", 100, "Facility capacity cost.") +_LOCATION_FRACTION = flags.DEFINE_float( + "location_fraction", + 0.001, + "Fraction of a facility's capacity that can be used by each location.", +) +_SOLVER_TYPE = flags.DEFINE_enum_class( + "solver_type", + mathopt.SolverType.GLOP, + mathopt.SolverType, + "the LP solver to use, possible values: glop, gurobi, glpk, pdlp.", +) + +_ZERO_TOL = 1.0e-3 + +################################################################################ +# Facility location instance representation and generation +################################################################################ + +# First element is a facility and second is a location. +Edge = Tuple[int, int] + + +class Network: + """A simple randomly-generated facility-location network.""" + + def __init__( + self, num_facilities: int, num_locations: int, edge_probability: float + ) -> None: + rng = np.random.default_rng(123) + self.num_facilities: int = num_facilities + self.num_locations: int = num_locations + self.facility_edge_incidence: List[List[Edge]] = [ + [] for facility in range(num_facilities) + ] + self.location_edge_incidence: List[List[Edge]] = [ + [] for location in range(num_locations) + ] + self.edges: List[Edge] = [] + self.edge_costs: Dict[Edge, float] = {} + for facility in range(num_facilities): + for location in range(num_locations): + if rng.binomial(1, edge_probability): + edge: Edge = (facility, location) + self.facility_edge_incidence[facility].append(edge) + self.location_edge_incidence[location].append(edge) + self.edges.append(edge) + self.edge_costs[edge] = rng.uniform() + + # Ensure every facility is connected to at least one location and every + # location is connected to at least one facility. + for facility in range(num_facilities): + if not self.facility_edge_incidence[facility]: + location = rng.integer(num_locations) + edge: Edge = (facility, location) + self.facility_edge_incidence[facility].append(edge) + self.location_edge_incidence[location].append(edge) + self.edges.append(edge) + self.edge_costs[edge] = rng.uniform() + for location in range(num_locations): + if not self.location_edge_incidence[location]: + facility = rng.integer(num_facilities) + edge: Edge = (facility, location) + self.facility_edge_incidence[facility].append(edge) + self.location_edge_incidence[location].append(edge) + self.edges.append(edge) + self.edge_costs[edge] = rng.uniform() + + +@dataclasses.dataclass +class FacilityLocationInstance: + """A facility location instance.""" + + network: Network + location_demand: float + facility_cost: float + location_fraction: float + + +################################################################################ +# Direct solve +################################################################################ + + +def full_problem( + instance: FacilityLocationInstance, solver_type: mathopt.SolverType +) -> None: + """Solves the full formulation of the facility location problem. + + See file level comment for problem description and formulation. + + Args: + instance: a facility location instance. + solver_type: what solver to use. + + Raises: + RuntimeError: On solve errors. + """ + num_facilities = instance.network.num_facilities + num_locations = instance.network.num_locations + model = mathopt.Model(name="Full network design problem") + + # Capacity variables + z = [model.add_variable(lb=0.0) for j in range(num_facilities)] + + # Flow variables + x = {edge: model.add_variable(lb=0.0) for edge in instance.network.edges} + + # Objective Function + objective_for_edges = sum( + instance.network.edge_costs[edge] * x[edge] for edge in instance.network.edges + ) + model.minimize(objective_for_edges + instance.facility_cost * sum(z)) + + # Demand constraints + for location in range(num_locations): + incoming_supply = sum( + x[edge] for edge in instance.network.location_edge_incidence[location] + ) + model.add_linear_constraint(incoming_supply >= instance.location_demand) + + # Supply constraints + for facility in range(num_facilities): + outgoing_supply = sum( + x[edge] for edge in instance.network.facility_edge_incidence[facility] + ) + model.add_linear_constraint(outgoing_supply <= z[facility]) + + # Arc constraints + for facility in range(num_facilities): + for edge in instance.network.facility_edge_incidence[facility]: + model.add_linear_constraint( + x[edge] <= instance.location_fraction * z[facility] + ) + + result = mathopt.solve(model, solver_type) + if result.termination.reason != mathopt.TerminationReason.OPTIMAL: + raise RuntimeError(f"failed to find an optimal solution: {result.termination}") + print(f"Fulll problem optimal objective: {result.objective_value():.9f}") + + +################################################################################ +# Benders solver +################################################################################ + + +# Setup first stage model: +# +# min c * sum(z_f : f in F) + w +# s.t. +# z_f >= 0 for all f in F +# sum(fcut_f^i z_f) + fcut_const^i <= 0 for i = 1,... +# sum(ocut_f^j z_f) + ocut_const^j <= w for j = 1,... +class FirstStageProblem: + """First stage model and variables.""" + + model: mathopt.Model + z: List[mathopt.Variable] + w: mathopt.Variable + + def __init__(self, network: Network, facility_cost: float) -> None: + self.model: mathopt.Model = mathopt.Model(name="First stage problem") + + # Capacity variables + self.z: List[mathopt.Variable] = [ + self.model.add_variable(lb=0.0) for j in range(network.num_facilities) + ] + + # Objective variable + self.w: mathopt.Variable = self.model.add_variable(lb=0.0) + + # First stage objective + self.model.minimize(self.w + facility_cost * sum(self.z)) + + +@dataclasses.dataclass +class Cut: + """A feasibility or optimality cut. + + The cut is of the form: + + z_coefficients^T z + constant <= w_coefficient * w + + This will be a feasibility cut if w_coefficient = 0.0 and an optimality + cut if w_coefficient = 1. + """ + + z_coefficients: List[float] = dataclasses.field(default_factory=list) + constant: float = 0.0 + w_coefficient: float = 0.0 + + +def ensure_dual_ray_solve_parameters( + solver_type: mathopt.SolverType, +) -> mathopt.SolveParameters: + """Set parameters to ensure a dual ray is returned for infeasible problems.""" + if solver_type == mathopt.SolverType.GUROBI: + return mathopt.SolveParameters( + gurobi=mathopt.GurobiParameters(param_values={"InfUnbdInfo": "1"}) + ) + if solver_type == mathopt.SolverType.GLOP: + return mathopt.SolveParameters( + presolve=mathopt.Emphasis.OFF, + scaling=mathopt.Emphasis.OFF, + lp_algorithm=mathopt.LPAlgorithm.DUAL_SIMPLEX, + ) + if solver_type == mathopt.SolverType.GLPK: + return mathopt.SolveParameters( + presolve=mathopt.Emphasis.OFF, + lp_algorithm=mathopt.LPAlgorithm.DUAL_SIMPLEX, + glpk=mathopt.GlpkParameters(compute_unbound_rays_if_possible=True), + ) + if solver_type == mathopt.SolverType.PDLP: + # No arguments needed. + return mathopt.SolveParameters() + raise ValueError(f"unsupported solver: {solver_type}") + + +class SecondStageSolver: + """Solves the second stage model. + + The model is: + + min sum(h_e * x_e : e in E) + s.t. + x_(f,l) <= a * zz_f for all (f,l) in E + sum(x_(f,l) : l such that (f,l) in E) <= zz_f for all f in F + sum(x_(f,l) : f such that (f,l) in E) >= d for all l in L + x_e >= 0 for all e in E + + where zz_f are fixed values for z_f from the first stage model, and generates + an infeasibility or optimality cut as needed. + """ + + def __init__( + self, instance: FacilityLocationInstance, solver_type: mathopt.SolverType + ) -> None: + self._network: Network = instance.network + self._second_stage_params: mathopt.SolveParameters = ( + ensure_dual_ray_solve_parameters(solver_type) + ) + self._location_fraction: float = instance.location_fraction + + num_facilities = self._network.num_facilities + num_locations = self._network.num_locations + self._second_stage_model = mathopt.Model(name="Second stage model") + + # Flow variables + self._x = { + edge: self._second_stage_model.add_variable(lb=0.0) + for edge in self._network.edges + } + + # Objective Function + objective_for_edges = sum( + self._network.edge_costs[edge] * self._x[edge] + for edge in self._network.edges + ) + self._second_stage_model.minimize(objective_for_edges) + + # Demand constraints + self._demand_constraints: List[mathopt.LinearConstraint] = [] + for location in range(num_locations): + incoming_supply = sum( + self._x[edge] + for edge in self._network.location_edge_incidence[location] + ) + self._demand_constraints.append( + self._second_stage_model.add_linear_constraint( + incoming_supply >= instance.location_demand + ) + ) + + # Supply constraints + self._supply_constraint: List[mathopt.LinearConstraint] = [] + for facility in range(num_facilities): + outgoing_supply = sum( + self._x[edge] + for edge in self._network.facility_edge_incidence[facility] + ) + # Set supply constraint with trivial upper bound to be updated with first + # stage information. + self._supply_constraint.append( + self._second_stage_model.add_linear_constraint( + outgoing_supply <= math.inf + ) + ) + + self._second_stage_solver = mathopt.IncrementalSolver( + self._second_stage_model, solver_type + ) + + def solve( + self, z_values: List[float], w_value: float, first_stage_objective: float + ) -> Tuple[float, Cut]: + """Solve the second stage problem.""" + num_facilities = self._network.num_facilities + # Update second stage with first stage solution. + for facility in range(num_facilities): + if z_values[facility] < -_ZERO_TOL: + raise RuntimeError( + "negative z_value in first stage: " + f"{z_values[facility]} for facility {facility}" + ) + # Make sure variable bounds are valid (lb <= ub). + capacity_value = max(z_values[facility], 0.0) + for edge in self._network.facility_edge_incidence[facility]: + self._x[edge].upper_bound = self._location_fraction * capacity_value + self._supply_constraint[facility].upper_bound = capacity_value + # Solve and process second stage. + second_stage_result = self._second_stage_solver.solve( + params=self._second_stage_params + ) + if second_stage_result.termination.reason not in ( + mathopt.TerminationReason.OPTIMAL, + mathopt.TerminationReason.INFEASIBLE, + ): + raise RuntimeError( + "second stage was not solved to optimality or infeasibility: " + f"{second_stage_result.termination}" + ) + if ( + second_stage_result.termination.reason + == mathopt.TerminationReason.INFEASIBLE + ): + # If the second stage problem is infeasible we can construct a + # feasibility cut from a returned dual ray. + return math.inf, self.feasibility_cut(second_stage_result) + else: + # If the second stage problem is optimal we can construct an optimality + # cut from a returned dual optimal solution. We can also update the upper + # bound, which is obtained by switching predicted second stage objective + # value w with the true second stage objective value. + upper_bound = ( + first_stage_objective - w_value + second_stage_result.objective_value() + ) + return upper_bound, self.optimality_cut(second_stage_result) + + def feasibility_cut(self, second_stage_result: mathopt.SolveResult) -> Cut: + """Build and return a feasibility cut. + + If the second stage problem is infeasible we get a dual ray (r, y) such + that + + sum(r_(f,l)*a*zz_f : (f,l) in E, r_(f,l) < 0) + + sum(y_f*zz_f : f in F, y_f < 0) + + sum(y_l*d : l in L, y_l > 0) > 0. + + Then we get the feasibility cut (go/math_opt-advanced-dual-use#benders) + + sum(fcut_f*z_f) + fcut_const <= 0, + + where + + fcut_f = sum(r_(f,l)*a : (f,l) in E, r_(f,l) < 0) + + min{y_f, 0} + fcut_const = sum*(y_l*d : l in L, y_l > 0) + + Args: + second_stage_result: The result from the second stage solve. + + Raises: + RuntimeError: if dual ray is not available. + + Returns: + A feasibility cut. + """ + num_facilities = self._network.num_facilities + result = Cut() + if not second_stage_result.has_dual_ray(): + # MathOpt does not require solvers to return a dual ray on infeasible, + # but most LP solvers always will, see go/mathopt-solver-contracts for + # details. + raise RuntimeError("no dual ray available for feasibility cut") + + for facility in range(num_facilities): + coefficient = 0.0 + for edge in self._network.facility_edge_incidence[facility]: + reduced_cost = second_stage_result.ray_reduced_costs(self._x[edge]) + coefficient += self._location_fraction * min(reduced_cost, 0.0) + dual_value = second_stage_result.ray_dual_values( + self._supply_constraint[facility] + ) + coefficient += min(dual_value, 0.0) + result.z_coefficients.append(coefficient) + result.constant = 0.0 + for constraint in self._demand_constraints: + dual_value = second_stage_result.ray_dual_values(constraint) + result.constant += max(dual_value, 0.0) + result.w_coefficient = 0.0 + return result + + def optimality_cut(self, second_stage_result: mathopt.SolveResult) -> Cut: + """Build and return an optimality cut. + + If the second stage problem is optimal we get a dual solution (r, y) such + that the optimal objective value is equal to + + sum(r_(f,l)*a*zz_f : (f,l) in E, r_(f,l) < 0) + + sum(y_f*zz_f : f in F, y_f < 0) + + sum*(y_l*d : l in L, y_l > 0) > 0. + + Then we get the optimality cut (go/math_opt-advanced-dual-use#benders) + + sum(ocut_f*z_f) + ocut_const <= w, + + where + + ocut_f = sum(r_(f,l)*a : (f,l) in E, r_(f,l) < 0) + + min{y_f, 0} + ocut_const = sum*(y_l*d : l in L, y_l > 0) + + Args: + second_stage_result: The result from the second stage solve. + + Raises: + RuntimeError: if dual solution is not available. + + Returns: + An optimality cut. + """ + num_facilities = self._network.num_facilities + result = Cut() + if not second_stage_result.has_dual_feasible_solution(): + # MathOpt does not require solvers to return a dual solution on optimal, + # but most LP solvers always will, see go/mathopt-solver-contracts for + # details. + raise RuntimeError("no dual solution available for optimality cut") + for facility in range(num_facilities): + coefficient = 0.0 + for edge in self._network.facility_edge_incidence[facility]: + reduced_cost = second_stage_result.reduced_costs(self._x[edge]) + coefficient += self._location_fraction * min(reduced_cost, 0.0) + dual_value = second_stage_result.dual_values( + self._supply_constraint[facility] + ) + coefficient += min(dual_value, 0.0) + result.z_coefficients.append(coefficient) + result.constant = 0.0 + for constraint in self._demand_constraints: + dual_value = second_stage_result.dual_values(constraint) + result.constant += max(dual_value, 0.0) + result.w_coefficient = 1.0 + return result + + +def benders( + instance: FacilityLocationInstance, + target_precission: float, + solver_type: mathopt.SolverType, + maximum_iterations: int = 30000, +) -> None: + """Solves the facility location problem using Benders decomposition. + + Args: + instance: a facility location instance. + target_precission: the target difference between upper and lower bounds. + solver_type: what solver to use. + maximum_iterations: limit on the number of iterations. + + Raises: + RuntimeError: On solve errors. + """ + num_facilities = instance.network.num_facilities + + # Setup first stage model and solver. + first_stage = FirstStageProblem(instance.network, instance.facility_cost) + first_stage_solver = mathopt.IncrementalSolver(first_stage.model, solver_type) + + # Setup second stage solver. + second_stage_solver = SecondStageSolver(instance, solver_type) + + # Start Benders + iteration = 0 + best_upper_bound = math.inf + while True: + print(f"Iteration: {iteration}", flush=True) + + # Solve and process first stage. + first_stage_result = first_stage_solver.solve() + if first_stage_result.termination.reason != mathopt.TerminationReason.OPTIMAL: + raise RuntimeError( + "could not solve first stage problem to optimality: " + f"{first_stage_result.termination}" + ) + z_values = [ + first_stage_result.variable_values(first_stage.z[j]) + for j in range(num_facilities) + ] + lower_bound = first_stage_result.objective_value() + print(f"LB = {lower_bound}", flush=True) + # Solve and process second stage. + upper_bound, cut = second_stage_solver.solve( + z_values, + first_stage_result.variable_values(first_stage.w), + first_stage_result.objective_value(), + ) + cut_expression = sum( + cut.z_coefficients[j] * first_stage.z[j] for j in range(num_facilities) + ) + cut_expression += cut.constant + first_stage.model.add_linear_constraint( + cut_expression <= cut.w_coefficient * first_stage.w + ) + best_upper_bound = min(upper_bound, best_upper_bound) + print(f"UB = {best_upper_bound}", flush=True) + iteration += 1 + if best_upper_bound - lower_bound < target_precission: + print(f"Total iterations = {iteration}", flush=True) + print(f"Final LB = {lower_bound:.9f}", flush=True) + print(f"Final UB = {best_upper_bound:.9f}", flush=True) + break + if iteration > maximum_iterations: + break + + +def main(argv: Sequence[str]) -> None: + del argv # Unused. + instance = FacilityLocationInstance( + network=Network( + _NUM_FACILITIES.value, _NUM_LOCATIONS.value, _EDGE_PROBABILITY.value + ), + location_demand=_LOCATION_DEMAND.value, + facility_cost=_FACILITY_COST.value, + location_fraction=_LOCATION_FRACTION.value, + ) + start = time.monotonic() + full_problem(instance, _SOLVER_TYPE.value) + print(f"Full solve time: {time.monotonic() - start}s") + start = time.monotonic() + benders(instance, _BENDERS_PRECISSION.value, _SOLVER_TYPE.value) + print(f"Benders solve time: {time.monotonic() - start}s") + + +if __name__ == "__main__": + app.run(main) diff --git a/ortools/math_opt/samples/python/facility_lp_benders_test.py b/ortools/math_opt/samples/python/facility_lp_benders_test.py new file mode 100644 index 0000000000..c6750a860b --- /dev/null +++ b/ortools/math_opt/samples/python/facility_lp_benders_test.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from google3.testing.pybase import parameterized +from ortools.math_opt.testing import binary_testing + +_BINARY_NAME = "ortools/math_opt/examples/python/facility_lp_benders" + + +class FacilityLpBendersTest(binary_testing.BinaryAssertions, parameterized.TestCase): + @parameterized.named_parameters( + ("_glop", "glop"), ("_glpk", "glpk"), ("_pdlp", "pdlp") + ) + def test_facility_lp_benders(self, solver: str) -> None: + result = self.assert_binary_succeeds( + _BINARY_NAME, ("--num_facilities", "10", "--solver_type", f"{solver}") + ) + self.assertIn("Benders solve time:", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/samples/python/integer_programming.py b/ortools/math_opt/samples/python/integer_programming.py new file mode 100644 index 0000000000..27299ff2d3 --- /dev/null +++ b/ortools/math_opt/samples/python/integer_programming.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Simple integer programming example.""" + +from typing import Sequence + +from absl import app + +from ortools.math_opt.python import mathopt + + +# Model and solve the problem: +# max x + 10 * y +# s.t. x + 7 * y <= 17.5 +# x <= 3.5 +# x in {0.0, 1.0, 2.0, ..., +# y in {0.0, 1.0, 2.0, ..., +# +def main(argv: Sequence[str]) -> None: + del argv # Unused. + + model = mathopt.Model(name="Linear programming example") + + # Variables + x = model.add_integer_variable(lb=0.0, name="x") + y = model.add_integer_variable(lb=0.0, name="y") + + # Constraints + model.add_linear_constraint(x + 7 * y <= 17.5, name="c1") + model.add_linear_constraint(x <= 3.5, name="c2") + + # Objective + model.maximize(x + 10 * y) + + # May raise a RuntimeError on invalid input or internal solver errors. + result = mathopt.solve(model, mathopt.SolverType.GSCIP) + + # A feasible solution is always available on termination reason kOptimal, + # and kFeasible, but in the later case the solution may be sub-optimal. + if result.termination.reason not in ( + mathopt.TerminationReason.OPTIMAL, + mathopt.TerminationReason.FEASIBLE, + ): + raise RuntimeError(f"model failed to solve: {result.termination}") + + print(f"Problem solved in {result.solve_time()}") + print(f"Objective value: {result.objective_value()}") + print( + f"Variable values: [x={round(result.variable_values()[x])}, " + f"y={round(result.variable_values()[y])}]" + ) + + +if __name__ == "__main__": + app.run(main) diff --git a/ortools/math_opt/samples/python/integer_programming_test.py b/ortools/math_opt/samples/python/integer_programming_test.py new file mode 100644 index 0000000000..dde4a401a4 --- /dev/null +++ b/ortools/math_opt/samples/python/integer_programming_test.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from ortools.math_opt.examples import log_scraping +from ortools.math_opt.testing import binary_testing + + +class IntegerProgrammingTest( + binary_testing.BinaryAssertions, + log_scraping.LogScraping, + unittest.TestCase, +): + def test_integer_programming_example(self) -> None: + result = self.assert_binary_succeeds( + "ortools/math_opt/examples/python/integer_programming" + ) + objective_value = self.assert_has_line_with_prefixed_number( + "Objective value:", result.stdout + ) + self.assertAlmostEqual(objective_value, 23, delta=1e-2) + self.assertIn("[x=3, y=2]", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/samples/python/linear_programming.py b/ortools/math_opt/samples/python/linear_programming.py new file mode 100644 index 0000000000..a53229e07e --- /dev/null +++ b/ortools/math_opt/samples/python/linear_programming.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Simple linear programming example.""" + +from typing import Sequence + +from absl import app + +from ortools.math_opt.python import mathopt + + +# Model and solve the problem: +# max 10 * x0 + 6 * x1 + 4 * x2 +# s.t. 10 * x0 + 4 * x1 + 5 * x2 <= 600 +# 2 * x0 + 2 * x1 + 6 * x2 <= 300 +# x0 + x1 + x2 <= 100 +# x0 in [0, infinity) +# x1 in [0, infinity) +# x2 in [0, infinity) +# +def main(argv: Sequence[str]) -> None: + del argv # Unused. + + model = mathopt.Model(name="Linear programming example") + + # Variables + x = [model.add_variable(lb=0.0, name=f"x{j}") for j in range(3)] + + # Constraints + model.add_linear_constraint(10 * x[0] + 4 * x[1] + 5 * x[2] <= 600, name="c1") + model.add_linear_constraint(2 * x[0] + 2 * x[1] + 6 * x[2] <= 300, name="c2") + model.add_linear_constraint(sum(x) <= 100, name="c3") + + # Objective + model.maximize(10 * x[0] + 6 * x[1] + 4 * x[2]) + + # May raise a RuntimeError on invalid input or internal solver errors. + result = mathopt.solve(model, mathopt.SolverType.GLOP) + + if result.termination.reason != mathopt.TerminationReason.OPTIMAL: + raise RuntimeError(f"model failed to solve to optimality: {result.termination}") + + print(f"Problem solved in {result.solve_time()}") + print(f"Objective value: {result.objective_value()}") + variable_values = [result.variable_values()[v] for v in x] + print(f"Variable values: {variable_values}") + + +if __name__ == "__main__": + app.run(main) diff --git a/ortools/math_opt/samples/python/linear_programming_test.py b/ortools/math_opt/samples/python/linear_programming_test.py new file mode 100644 index 0000000000..7da20bcd0a --- /dev/null +++ b/ortools/math_opt/samples/python/linear_programming_test.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from ortools.math_opt.examples import log_scraping +from ortools.math_opt.testing import binary_testing + + +class LinearProgrammingTest( + binary_testing.BinaryAssertions, + log_scraping.LogScraping, + unittest.TestCase, +): + def test_linear_programming_example(self) -> None: + result = self.assert_binary_succeeds( + "ortools/math_opt/examples/python/linear_programming" + ) + objective_value = self.assert_has_line_with_prefixed_number( + "Objective value:", result.stdout + ) + self.assertAlmostEqual(objective_value, 733 + 1 / 3, delta=1e-2) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/samples/python/linear_regression.py b/ortools/math_opt/samples/python/linear_regression.py new file mode 100644 index 0000000000..93ca2ee8f8 --- /dev/null +++ b/ortools/math_opt/samples/python/linear_regression.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Solve a linear regression problem on random data. + +Problem data: + There are num_features features, indexed by j. + There are num_examples training examples indexed by i. + x_ij: The feature value for example i and feature j in the training data. + y_i: The label for example i in the training data. + +Decision variables: + beta_j: the coefficient to learn for each feature j. + z_i: the prediction error for example i. + +Optimization problem: + min sum_i z_i^2 + s.t. y_i - sum_j beta_j * x_ij = z_i + +This is the unregularized linear regression problem. + +This example solves the problem on randomly generated (x, y) data. The data +is generated by assuming some true values for beta (generated at random, +i.i.d. N(0, 1)), then drawing each x_ij as N(0, 1) and then computing + y_i = beta * x_i + N(0, noise) +where noise is a command line flag. + +After solving the optimization problem above to recover values for beta, the +in sample and out of sample loss (average squared prediction error) for the +learned model are printed. + +For an advanced version, see: + ortools/math_opt/codelabs/regression/ +""" +import dataclasses +from typing import Sequence + +from absl import app +from absl import flags +import numpy as np + +from ortools.math_opt.python import mathopt + +_SOLVER_TYPE = flags.DEFINE_enum_class( + "solver_type", + mathopt.SolverType.PDLP, + mathopt.SolverType, + "The solver needs to support quadratic objectives, e.g. pdlp, gurobi, or " "osqp.", +) + +_NUM_FEATURES = flags.DEFINE_integer( + "num_features", 10, "The number of features in the linear regression model." +) + +_NUM_EXAMPLES = flags.DEFINE_integer( + "num_examples", + 100, + "The number of examples to use in the train and test sets.", +) + +_NOISE = flags.DEFINE_float( + "noise", 3.0, "The standard deviation of the noise on the labels." +) + + +@dataclasses.dataclass +class LabeledData: + xs: np.ndarray + ys: np.ndarray + + +def random_data( + betas: np.ndarray, + num_examples: int, + noise_stddev: float, + rng: np.random._generator.Generator, +) -> LabeledData: + """Creates randomly perturbed labeled data from a ground truth beta.""" + num_features = betas.shape[0] + xs = rng.standard_normal(size=(num_examples, num_features)) + ys = xs @ betas + rng.normal(0, noise_stddev, size=(num_examples)) + return LabeledData(xs=xs, ys=ys) + + +def l2_loss(betas: np.ndarray, labeled_data: LabeledData) -> float: + """Computes the average squared error between model(labeled_data.xs) and labeled_data.y.""" + num_examples = labeled_data.xs.shape[0] + if num_examples == 0: + return 0 + residuals = labeled_data.xs @ betas - labeled_data.ys + return np.inner(residuals, residuals) / num_examples + + +def train(labeled_data: LabeledData, solver_type: mathopt.SolverType) -> np.ndarray: + """Returns minimum L2Loss beta on labeled_data by solving a quadratic optimization problem.""" + num_examples, num_features = labeled_data.xs.shape + + model = mathopt.Model(name="linear_regression") + + # Create the decision variables: beta, and z. + betas = [model.add_variable(name=f"beta_{j}") for j in range(num_features)] + zs = [model.add_variable(name=f"z_{i}") for i in range(num_examples)] + + # Set the objective function: + model.minimize(sum(z * z for z in zs)) + + # Add the constraints: + # z_i = y_i - x_i * beta + for i in range(num_examples): + model.add_linear_constraint( + zs[i] + == labeled_data.ys[i] + - sum(betas[j] * labeled_data.xs[i, j] for j in range(num_features)) + ) + + # Done building the model, now solve. + result = mathopt.solve( + model, solver_type, params=mathopt.SolveParameters(enable_output=True) + ) + if result.termination.reason != mathopt.TerminationReason.OPTIMAL: + raise RuntimeError( + "Expected termination reason optimal, but termination was: " + f"{result.termination}" + ) + print(f"Training time: {result.solve_time()}") + return np.array(result.variable_values(betas)) + + +def main(argv: Sequence[str]) -> None: + del argv # Unused. + + num_features = _NUM_FEATURES.value + num_examples = _NUM_EXAMPLES.value + noise_stddev = _NOISE.value + + rng = np.random.default_rng(123) + + ground_truth_betas = rng.standard_normal(size=(num_features)) + train_data = random_data(ground_truth_betas, num_examples, noise_stddev, rng) + test_data = random_data(ground_truth_betas, num_examples, noise_stddev, rng) + + learned_beta = train(train_data, _SOLVER_TYPE.value) + print(f"In sample loss: {l2_loss(learned_beta, train_data)}") + print(f"Out of sample loss: {l2_loss(learned_beta, test_data)}") + + +if __name__ == "__main__": + app.run(main) diff --git a/ortools/math_opt/samples/python/linear_regression_test.py b/ortools/math_opt/samples/python/linear_regression_test.py new file mode 100644 index 0000000000..5c31f28957 --- /dev/null +++ b/ortools/math_opt/samples/python/linear_regression_test.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for linear_regression.py.""" + +import unittest +from ortools.math_opt.examples import log_scraping +from ortools.math_opt.testing import binary_testing + + +class RegressionTest( + binary_testing.BinaryAssertions, + log_scraping.LogScraping, + unittest.TestCase, +): + def test_regression(self): + result = self.assert_binary_succeeds( + "ortools/math_opt/examples/python/linear_regression" + ) + oos_loss = self.assert_has_line_with_prefixed_number( + "Out of sample loss: ", result.stdout + ) + self.assertLessEqual(oos_loss, 20.0) + self.assertGreaterEqual(oos_loss, 5.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/samples/python/remote_solve.py b/ortools/math_opt/samples/python/remote_solve.py new file mode 100644 index 0000000000..4147fd5c74 --- /dev/null +++ b/ortools/math_opt/samples/python/remote_solve.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Testing correctness of the code snippets in the comments of model.py.""" +import datetime +from typing import Sequence + +from absl import app +from absl import flags + +from ortools.math_opt.python import mathopt +from ortools.math_opt.python.ipc import remote_solve +from ortools.math_opt.python.ipc import solve_service_stubby_client + +_SOLVER = flags.DEFINE_enum_class( + "solver", mathopt.SolverType.GSCIP, mathopt.SolverType, "The solver to use." +) +_INTEGER = flags.DEFINE_bool( + "integer", False, "If the variable should be integer or continuous." +) + +_LOGS = flags.DEFINE_bool("logs", True, "Prints solver logs.") +_MODE = flags.DEFINE_enum_class( + "mode", + remote_solve.RemoteSolveMode.DEFAULT, + remote_solve.RemoteSolveMode, + "The mode to use with remote_solve().", +) + + +def main(argv: Sequence[str]) -> None: + del argv # Unused. + + model = mathopt.Model(name="my_model") + x = model.add_variable(lb=0.0, ub=1.0, is_integer=_INTEGER.value, name="x") + y = model.add_variable(lb=0.0, ub=1.0, is_integer=_INTEGER.value, name="y") + model.add_linear_constraint(x + y <= 1.0, name="c") + model.maximize(2 * x + 3 * y) + + stub = None + if _MODE.value in ( + remote_solve.RemoteSolveMode.DEFAULT, + remote_solve.RemoteSolveMode.STREAMING, + ): + stub = solve_service_stubby_client.solve_server_stub() + + msg_cb = None + if _LOGS.value: + msg_cb = mathopt.printer_message_callback(prefix="Solver log: ") + # Raises exceptions on invalid input, internal solver error, rpc timeout etc. + result = remote_solve.remote_solve( + stub, + model, + _SOLVER.value, + mode=_MODE.value, + msg_cb=msg_cb, + rpc_time_limit=datetime.timedelta(minutes=1), + ) + + if result.termination.reason not in ( + mathopt.TerminationReason.OPTIMAL, + mathopt.TerminationReason.FEASIBLE, + ): + raise RuntimeError(f"model failed to solve: {result.termination}") + + print(f"Objective value: {result.objective_value()}") + print(f"Value for variable x: {result.variable_values()[x]}") + + +if __name__ == "__main__": + app.run(main) diff --git a/ortools/math_opt/samples/python/remote_solve_test.py b/ortools/math_opt/samples/python/remote_solve_test.py new file mode 100644 index 0000000000..fc7e693183 --- /dev/null +++ b/ortools/math_opt/samples/python/remote_solve_test.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for remote_solver.py binary.""" + +import unittest +from ortools.math_opt.examples import log_scraping +from ortools.math_opt.testing import binary_testing + + +class RemoteSolveTest( + binary_testing.BinaryAssertions, + log_scraping.LogScraping, + unittest.TestCase, +): + def test_local_solve_mode(self): + result = self.assert_binary_succeeds( + "ortools/math_opt/examples/python/remote_solve", + ("--mode", "in_process"), + ) + objective_value = self.assert_has_line_with_prefixed_number( + "Objective value:", result.stdout + ) + self.assertAlmostEqual(objective_value, 3.0, delta=1e-2) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/samples/python/time_indexed_scheduling.py b/ortools/math_opt/samples/python/time_indexed_scheduling.py new file mode 100644 index 0000000000..773eb76ba4 --- /dev/null +++ b/ortools/math_opt/samples/python/time_indexed_scheduling.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Solve a time-indexed scheduling problem. + +Problem statement: + +Schedule jobs sequentially (on a single machine) to minimize the sum of the +completion times, where each job cannot start until its release time. In the +scheduling literature, this problem is 1|r_i|sum_i C_i. This problem is NP-Hard +(e.g. see "Elements of Scheduling" by Lenstra and Shmoys 2020, chapter 4). + +Data: + * n jobs + * processing time p_i for i = 1,...,n + * release time r_i for i = 1,...,n + * Implied: T = max_i r_i + sum_i p_i, the time horizon, all jobs must start + in [0, T]. + +Variables: + * x_it for job i = 1,...,n and time t = 1,...,T, if job i starts at time t. + +Model: + min sum_i sum_t (t + p_i) * x_it + s.t. sum_t x_it = 1 for all i = 1,...,n (1) + sum_i sum_{s=t-p_i+1}^t x_is <= 1 for all t = 0,...,T (2) + x_it = 0 for all i, for t < r_i (3) + x_it in {0, 1} for all i and t + +In the objective, t + p_i is the time the job is completed if it starts at t. +Constraint (1) ensures that each job is scheduled once, constraint (2) +ensures that no two jobs overlap in when they are running, and constraint (3) +enforces the release dates. +""" + +import dataclasses +import random +from typing import Sequence, Tuple + +from absl import app +from absl import flags + +from ortools.math_opt.python import mathopt + +_SOLVER_TYPE = flags.DEFINE_enum_class( + "solver_type", + mathopt.SolverType.GSCIP, + mathopt.SolverType, + "The solver needs to support binary IP.", +) + +_NUM_JOBS = flags.DEFINE_integer("num_jobs", 30, "How many jobs to schedule.") + +_USE_TEST_DATA = flags.DEFINE_boolean( + "use_test_data", + False, + "Solve a small hardcoded instance instead of a large random one.", +) + + +@dataclasses.dataclass(frozen=True) +class Jobs: + """Data for jobs in a time-indexed scheduling problem. + + Attributes: + processing_times: The duration of each job. + release_times: The earliest time at which each job can begin. + """ + + processing_times: Tuple[int, ...] + release_times: Tuple[int, ...] + + +def random_jobs(num_jobs: int) -> Jobs: + """Generates a random set of jobs to be scheduled.""" + # Processing times are uniform in [1, processing_time_ub]. + processing_time_ub = 20 + + # Release times are uniform in [0, release_time_ub]. + release_time_ub = num_jobs * processing_time_ub // 2 + + processing_times: Tuple[int, ...] = tuple( + random.randrange(1, processing_time_ub) for _ in range(num_jobs) + ) + release_times: Tuple[int, ...] = tuple( + random.randrange(1, release_time_ub) for _ in range(num_jobs) + ) + + return Jobs(processing_times=processing_times, release_times=release_times) + + +# A small instance for testing. The optimal solution is to run: +# Job 1 at time 1 +# Job 2 at time 2 +# Job 0 at time 7 +# This gives a sum of completion times of 2 + 7 + 17 = 26. +# +# Note that the above schedule idles at time 0. If instead, we did +# Job 2 at time 0 +# Job 1 at time 5 +# Job 0 at time 6 +# This gives a sum of completion times of 5 + 6 + 16 = 27. +def _test_instance() -> Jobs: + return Jobs(processing_times=(10, 1, 5), release_times=(0, 1, 0)) + + +def time_horizon(jobs: Jobs) -> int: + """Computes the time horizon of the problem.""" + max_release = max(jobs.release_times, default=0) + sum_processing = sum(jobs.processing_times) + return max_release + sum_processing + + +@dataclasses.dataclass(frozen=True) +class Schedule: + """The solution to a time-indexed scheduling problem. + + Attributes: + start_times: The time at which each job begins. + sum_of_completion_times: The sum of times at which jobs complete. + """ + + start_times: Tuple[int, ...] = () + sum_of_completion_times: int = 0 + + +def solve(jobs: Jobs, solver_type: mathopt.SolverType) -> Schedule: + """Solves a time indexed scheduling problem, returning the best schedule. + + Args: + jobs: The jobs to be scheduled, each with processing and release times. + solver_type: The IP solver used to solve the problem. + + Returns: + The schedule of jobs that minimizes the sum of completion times. + + Raises: + RuntimeError: On solve errors. + """ + processing_times = jobs.processing_times + release_times = jobs.release_times + assert len(processing_times) == len(release_times) + num_jobs = len(processing_times) + + horizon = time_horizon(jobs) + model = mathopt.Model(name="time_indexed_scheduling") + + sum_completion_times = 0 + + # x[i][t] == 1 indicates that we start job i at time t. + x = [[] for i in range(num_jobs)] + + for i in range(num_jobs): + for t in range(horizon): + v = model.add_binary_variable(name=f"x_{i}_{t}") + completion_time = t + processing_times[i] + sum_completion_times += completion_time * v + if t < release_times[i]: + v.upper_bound = 0 + x[i].append(v) + + # Pick one time to run job i. + model.add_linear_constraint(sum(x[i]) == 1) + + model.minimize(sum_completion_times) + + # Run at most one job at a time. + for t in range(horizon): + conflicts = 0 + for i in range(num_jobs): + for s in range(max(0, t - processing_times[i] + 1), t + 1): + conflicts += x[i][s] + model.add_linear_constraint(conflicts <= 1) + + result = mathopt.solve(model, solver_type) + + if result.termination.reason != mathopt.TerminationReason.OPTIMAL: + raise RuntimeError( + "Failed to solve time-indexed scheduling problem to " + f" optimality: {result.termination}" + ) + + start_times = [] + + # Add the start times for the jobs. + for i in range(num_jobs): + for t in range(horizon): + var_value = result.variable_values(x[i][t]) + if var_value > 0.5: + start_times.append(t) + break + + return Schedule(tuple(start_times), int(round(result.objective_value()))) + + +def print_schedule(jobs: Jobs, schedule: Schedule) -> None: + """Displays the schedule, one job per line.""" + processing_times = jobs.processing_times + release_times = jobs.release_times + num_jobs = len(processing_times) + start_times = schedule.start_times + + print("Sum of completion times:", schedule.sum_of_completion_times) + jobs_by_start_time = [] + + for i in range(num_jobs): + jobs_by_start_time.append( + (start_times[i], processing_times[i], release_times[i]) + ) + + jobs_by_start_time.sort(key=lambda job: job[0]) + + print("Start time, processing time, release time:") + for job in jobs_by_start_time: + print(job[0], job[1], job[2]) + + +def main(argv: Sequence[str]) -> None: + del argv # Unused. + if _USE_TEST_DATA.value: + jobs = _test_instance() + schedule = solve(jobs, _SOLVER_TYPE.value) + else: + jobs = random_jobs(_NUM_JOBS.value) + schedule = solve(jobs, _SOLVER_TYPE.value) + + print_schedule(jobs, schedule) + + +if __name__ == "__main__": + app.run(main) diff --git a/ortools/math_opt/samples/python/time_indexed_scheduling_test.py b/ortools/math_opt/samples/python/time_indexed_scheduling_test.py new file mode 100644 index 0000000000..ef5b16672f --- /dev/null +++ b/ortools/math_opt/samples/python/time_indexed_scheduling_test.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from ortools.math_opt.examples import log_scraping +from ortools.math_opt.testing import binary_testing + +_BIN_PATH = "ortools/math_opt/examples/python/time_indexed_scheduling" + + +class TimeIndexedSchedulingTest( + binary_testing.BinaryAssertions, + log_scraping.LogScraping, + unittest.TestCase, +): + def test_simple_jobs_schedule(self) -> None: + output = self.assert_binary_succeeds(_BIN_PATH, ("--use_test_data",)) + sum_completion_time = self.assert_has_line_with_prefixed_number( + "Sum of completion times:", output.stdout + ) + self.assertEqual(sum_completion_time, 26) + + def test_random_jobs_schedule(self) -> None: + output = self.assert_binary_succeeds(_BIN_PATH, ("--num_jobs=10",)) + sum_completion_time = self.assert_has_line_with_prefixed_number( + "Sum of completion times:", output.stdout + ) + self.assertGreaterEqual(sum_completion_time, 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/ortools/math_opt/samples/python/tsp.py b/ortools/math_opt/samples/python/tsp.py new file mode 100644 index 0000000000..a7e10859ce --- /dev/null +++ b/ortools/math_opt/samples/python/tsp.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Solve the traveling salesperson problem (TSP) with MIP. + +In the Euclidean Traveling Salesperson Problem (TSP), you are given a list of +n cities, each with an (x, y) coordinate, and you must find an order to visit +the cities in to minimize the (Euclidean) travel distance. + +The MIP "cutset" formulation for the problem is as follows: + * Data: + n: An integer, the number of cities + (x_i, y_i): a pair of floats for each i in 1..n, the location of each + city + d_ij for all (i, j) pairs of cities, the distance between city i and j. + * Decision variables: + x_ij: A binary variable, indicates if the edge connecting i and j is + used. Note that x_ij == x_ji, because the problem is symmetric. We + only create variables for i < j, and have x_ji as an alias for + x_ij. + * MIP model: + minimize sum_{i=1}^n sum_{j=1, j < i}^n d_ij * x_ij + s.t. sum_{j=1, j != i}^n x_ij = 2 for all i = 1..n + sum_{i in S} sum_{j not in S} x_ij >= 2 for all S subset {1,...,n} + |S| >= 3, |S| <= n - 3 + x_ij in {0, 1} +The first set of constraints are called the degree constraints, and the +second set of constraints are called the cutset constraints. There are +exponentially many cutset, so we cannot add them all at the start of the +solve. Instead, we will use a solver callback to view each integer solution +and add any violated cutset constraints that exist. + +Note that, while there are exponentially many cutset constraints, we can +quickly identify violated ones by exploiting that the solution is integer +and the degree constraints are all already in the model and satisfied. As a +result, the graph n nodes and edges when x_ij = 1 will be a degree two graph, +so it will be a collection of cycles. If it is a single large cycle, then the +solution is feasible, and if there multiple cycles, then taking the nodes of +any cycle as S produces a violated cutset constraint. + +Note that this is a minimal TSP solution, more sophisticated MIP methods are +possible. +""" +import itertools +import math +import random +from typing import Dict, List, Optional, Tuple + +from absl import app +from absl import flags +import svgwrite + +from ortools.math_opt.python import mathopt + +_NUM_CITIES = flags.DEFINE_integer("num_cities", 100, "The size of the TSP instance.") +_OUTPUT = flags.DEFINE_string("output", "", "Where to write an output SVG, if nonempty") +_TEST_INSTANCE = flags.DEFINE_boolean( + "test_instance", + False, + "Use a small test instance instead of a random instance.", +) +_SOLVE_LOGS = flags.DEFINE_boolean( + "solve_logs", False, "Have the solver print logs to standard out." +) + +Cities = List[Tuple[float, float]] + + +def _random_cities(num_cities: int) -> Cities: + """Returns a list random entries distributed U[0,1]^2 i.i.d.""" + return [(random.random(), random.random()) for _ in range(num_cities)] + + +def _test_instance() -> Cities: + return [ + (0.0, 0.0), + (0.1, 0.0), + (0.0, 0.1), + (0.1, 0.1), + (0.0, 0.9), + (0.1, 0.9), + (0.0, 1.0), + (0.1, 1.0), + ] + + +def _distance_matrix(cities: Cities) -> List[List[float]]: + """Converts a list of (x,y) pairs into a a matrix of Eucledian distances.""" + n = len(cities) + res = [[0.0] * n for _ in range(n)] + for i in range(n): + for j in range(i + 1, n): + xi, yi = cities[i] + xj, yj = cities[j] + dx = xi - xj + dy = yi - yj + dist = math.sqrt(dx * dx + dy * dy) + res[i][j] = dist + res[j][i] = dist + return res + + +def _edge_values( + edge_vars: List[List[Optional[mathopt.Variable]]], + var_values: Dict[mathopt.Variable, float], +) -> List[List[bool]]: + """Converts edge decision variables into an adjacency matrix.""" + n = len(edge_vars) + res = [[False] * n for _ in range(n)] + for i in range(n): + for j in range(n): + if i != j: + res[i][j] = var_values[edge_vars[i][j]] > 0.5 + return res + + +def _find_cycles(edges: List[List[bool]]) -> List[List[int]]: + """Finds the cycle decomposition for a degree two graph as adjacenty matrix.""" + n = len(edges) + cycles = [] + visited = [False] * n + # Algorithm: maintain a "visited" bit for each city indicating if we have + # formed a cycle containing this city. Consider the cities in order. When you + # find an unvisited city, start a new cycle beginning at this city. Then, + # build the cycle by finding an unvisited neighbor until no such neighbor + # exists (every city will have two neighbors, but eventually both will be + # visited). To find the "unvisited neighbor", we simply do a linear scan + # over the cities, checking both the adjacency matrix and the visited bit. + # + # Note that for this algorithm, in each cycle, the city with lowest index + # will be first, and the cycles will be sorted by their city of lowest index. + # This is an implementation detail and should not be relied upon. + for i in range(n): + if visited[i]: + continue + cycle = [] + next_city = i + while next_city is not None: + cycle.append(next_city) + visited[next_city] = True + current = next_city + next_city = None + # Scan for an unvisited neighbor. We can start at i+1 since we know that + # everything from i back is visited. + for j in range(i + 1, n): + if (not visited[j]) and edges[current][j]: + next_city = j + break + cycles.append(cycle) + return cycles + + +def solve_tsp(cities: Cities) -> List[int]: + """Solves the traveling salesperson problem and returns the best route.""" + n = len(cities) + dist = _distance_matrix(cities) + model = mathopt.Model(name="tsp") + edges = [[None] * n for _ in range(n)] + for i in range(n): + for j in range(i + 1, n): + v = model.add_binary_variable(name=f"x_{i}_{j}") + edges[i][j] = v + edges[j][i] = v + obj = 0 + for i in range(n): + obj += sum(dist[i][j] * edges[i][j] for j in range(i + 1, n)) + model.minimize(obj) + for i in range(n): + model.add_linear_constraint(sum(edges[i][j] for j in range(n) if j != i) == 2.0) + + def cb(cb_data: mathopt.CallbackData) -> mathopt.CallbackResult: + assert cb_data.solution is not None + cycles = _find_cycles(_edge_values(edges, cb_data.solution)) + result = mathopt.CallbackResult() + if len(cycles) > 1: + for cycle in cycles: + cycle_as_set = set(cycle) + not_in_cycle = [i for i in range(n) if i not in cycle_as_set] + result.add_lazy_constraint( + sum( + edges[i][j] for (i, j) in itertools.product(cycle, not_in_cycle) + ) + >= 2.0 + ) + return result + + result = mathopt.solve( + model, + mathopt.SolverType.GUROBI, + params=mathopt.SolveParameters(enable_output=_SOLVE_LOGS.value), + callback_reg=mathopt.CallbackRegistration( + events={mathopt.Event.MIP_SOLUTION}, add_lazy_constraints=True + ), + cb=cb, + ) + assert ( + result.termination.reason == mathopt.TerminationReason.OPTIMAL + ), result.termination + assert result.solutions[0].primal_solution is not None + print(f"Route length: {result.solutions[0].primal_solution.objective_value}") + cycles = _find_cycles( + _edge_values(edges, result.solutions[0].primal_solution.variable_values) + ) + assert len(cycles) == 1, len(cycles) + route = cycles[0] + assert len(route) == n, (len(route), n) + return route + + +def route_svg(filename: str, cities: Cities, route: List[int]): + """Draws the route as an SVG and writes to disk (or prints if no filename).""" + resolution = 1000 + r = 5 + drawing = svgwrite.Drawing( + filename=filename, + size=(resolution + 2 * r, resolution + 2 * r), + profile="tiny", + ) + polygon_points = [] + scale = lambda x: int(round(x * resolution)) + r + for city in route: + raw_x, raw_y = cities[city] + c = (scale(raw_x), scale(raw_y)) + polygon_points.append(c) + drawing.add(drawing.circle(center=c, r=r, fill="blue")) + drawing.add(drawing.polygon(points=polygon_points, stroke="blue", fill="none")) + if not filename: + print(drawing.tostring()) + else: + drawing.save() + + +def main(args): + del args # Unused. + if _TEST_INSTANCE.value: + cities = _test_instance() + else: + cities = _random_cities(_NUM_CITIES.value) + route = solve_tsp(cities) + route_svg(_OUTPUT.value, cities, route) + + +if __name__ == "__main__": + app.run(main) diff --git a/ortools/math_opt/samples/python/tsp_test.py b/ortools/math_opt/samples/python/tsp_test.py new file mode 100644 index 0000000000..d1f3541f6f --- /dev/null +++ b/ortools/math_opt/samples/python/tsp_test.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from ortools.math_opt.examples import log_scraping +from ortools.math_opt.testing import binary_testing + +_TSP_PATH = "ortools/math_opt/examples/python/tsp" + + +class TspTest( + binary_testing.BinaryAssertions, + log_scraping.LogScraping, + unittest.TestCase, +): + def test_tsp_simple(self) -> None: + output = self.assert_binary_succeeds( + _TSP_PATH, ("--test_instance", "--solve_logs") + ) + lazy = self.assert_has_line_with_prefixed_number( + " Lazy constraints: ", output.stdout + ) + self.assertEqual(lazy, 1) + route_length = self.assert_has_line_with_prefixed_number( + "Route length: ", output.stdout + ) + self.assertAlmostEqual(route_length, 2.2) + + def test_tsp_large_no_crash(self) -> None: + output = self.assert_binary_succeeds(_TSP_PATH) + self.assertIn("Route length:", output.stdout) + + +if __name__ == "__main__": + unittest.main()