move math_opt samples; add python code + samples

This commit is contained in:
Laurent Perron
2023-11-17 16:25:02 +01:00
parent 467c5f4855
commit 84962a4281
78 changed files with 19945 additions and 10 deletions

View File

@@ -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",
],
)

View File

@@ -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 <pybind11/functional.h>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <functional>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#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<CallbackResultProto(CallbackDataProto)>;
using PybindSolverMessageCallback =
std::function<void(std::vector<std::string>)>;
// Wrapper for Solver::NonIncrementalSolve with flat arguments.
absl::StatusOr<SolveResultProto> 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<ComputeInfeasibleSubsystemResultProto>
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<std::unique_ptr<PybindSolver>> New(
const SolverTypeProto solver_type, const ModelProto& model,
SolverInitializerProto solver_initializer) {
ASSIGN_OR_RETURN(
std::unique_ptr<Solver> solver,
Solver::New(solver_type, model,
{.streamable = std::move(solver_initializer)}));
return absl::WrapUnique<PybindSolver>(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<SolveResultProto> 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<bool> Update(const ModelUpdateProto& model_update) {
return solver_->Update(model_update);
}
private:
explicit PybindSolver(std::unique_ptr<Solver> solver)
: solver_(std::move(solver)) {}
const std::unique_ptr<Solver> 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<py::gil_scoped_release>());
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<py::gil_scoped_release>());
m.def("new", &PybindSolver::New, py::arg("solver_type"), py::arg("model"),
py::arg("solver_initializer"),
py::call_guard<py::gil_scoped_release>());
m.def("debug_num_solver", &PybindSolver::DebugNumSolver);
py::class_<PybindSolver>(m, "Solver")
.def("solve", &PybindSolver::Solve,
py::call_guard<py::gil_scoped_release>())
.def("update", &PybindSolver::Update,
py::call_guard<py::gil_scoped_release>());
py::class_<SolveInterrupter>(m, "SolveInterrupter")
.def(py::init())
.def("interrupt", &SolveInterrupter::Interrupt)
.def("is_interrupted", &SolveInterrupter::IsInterrupted);
}
} // namespace operations_research::math_opt

View File

@@ -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()

View File

@@ -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()

View File

@@ -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",
],
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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()

View File

@@ -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]

View File

@@ -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()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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]

View File

@@ -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()

View File

@@ -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()
),
)

View File

@@ -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()

View File

@@ -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",
],
)

View File

@@ -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()

View File

@@ -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",
],
)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()