move math_opt samples; add python code + samples
This commit is contained in:
33
ortools/math_opt/core/python/BUILD.bazel
Normal file
33
ortools/math_opt/core/python/BUILD.bazel
Normal 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",
|
||||
],
|
||||
)
|
||||
152
ortools/math_opt/core/python/solver.cc
Normal file
152
ortools/math_opt/core/python/solver.cc
Normal 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
|
||||
180
ortools/math_opt/core/python/solver_gurobi_test.py
Normal file
180
ortools/math_opt/core/python/solver_gurobi_test.py
Normal 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()
|
||||
273
ortools/math_opt/core/python/solver_test.py
Normal file
273
ortools/math_opt/core/python/solver_test.py
Normal 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()
|
||||
190
ortools/math_opt/python/BUILD.bazel
Normal file
190
ortools/math_opt/python/BUILD.bazel
Normal 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",
|
||||
],
|
||||
)
|
||||
346
ortools/math_opt/python/callback.py
Normal file
346
ortools/math_opt/python/callback.py
Normal 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
|
||||
253
ortools/math_opt/python/callback_test.py
Normal file
253
ortools/math_opt/python/callback_test.py
Normal 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()
|
||||
197
ortools/math_opt/python/compute_infeasible_subsystem_result.py
Normal file
197
ortools/math_opt/python/compute_infeasible_subsystem_result.py
Normal 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,
|
||||
)
|
||||
@@ -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()
|
||||
843
ortools/math_opt/python/hash_model_storage.py
Normal file
843
ortools/math_opt/python/hash_model_storage.py
Normal 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)
|
||||
30
ortools/math_opt/python/hash_model_storage_test.py
Normal file
30
ortools/math_opt/python/hash_model_storage_test.py
Normal 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()
|
||||
2797
ortools/math_opt/python/linear_expression_test.py
Normal file
2797
ortools/math_opt/python/linear_expression_test.py
Normal file
File diff suppressed because it is too large
Load Diff
169
ortools/math_opt/python/mathopt.py
Normal file
169
ortools/math_opt/python/mathopt.py
Normal 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
|
||||
108
ortools/math_opt/python/mathopt_test.py
Normal file
108
ortools/math_opt/python/mathopt_test.py
Normal 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()
|
||||
142
ortools/math_opt/python/message_callback.py
Normal file
142
ortools/math_opt/python/message_callback.py
Normal 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
|
||||
134
ortools/math_opt/python/message_callback_test.py
Normal file
134
ortools/math_opt/python/message_callback_test.py
Normal 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()
|
||||
2253
ortools/math_opt/python/model.py
Normal file
2253
ortools/math_opt/python/model.py
Normal file
File diff suppressed because it is too large
Load Diff
155
ortools/math_opt/python/model_parameters.py
Normal file
155
ortools/math_opt/python/model_parameters.py
Normal 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
|
||||
109
ortools/math_opt/python/model_parameters_test.py
Normal file
109
ortools/math_opt/python/model_parameters_test.py
Normal 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()
|
||||
440
ortools/math_opt/python/model_storage.py
Normal file
440
ortools/math_opt/python/model_storage.py
Normal 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]
|
||||
928
ortools/math_opt/python/model_storage_test.py
Normal file
928
ortools/math_opt/python/model_storage_test.py
Normal 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()
|
||||
1174
ortools/math_opt/python/model_storage_update_test.py
Normal file
1174
ortools/math_opt/python/model_storage_update_test.py
Normal file
File diff suppressed because it is too large
Load Diff
1110
ortools/math_opt/python/model_test.py
Normal file
1110
ortools/math_opt/python/model_test.py
Normal file
File diff suppressed because it is too large
Load Diff
77
ortools/math_opt/python/normalize.py
Normal file
77
ortools/math_opt/python/normalize.py
Normal 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)
|
||||
127
ortools/math_opt/python/normalize_test.py
Normal file
127
ortools/math_opt/python/normalize_test.py
Normal 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()
|
||||
455
ortools/math_opt/python/parameters.py
Normal file
455
ortools/math_opt/python/parameters.py
Normal 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
|
||||
230
ortools/math_opt/python/parameters_test.py
Normal file
230
ortools/math_opt/python/parameters_test.py
Normal 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()
|
||||
1006
ortools/math_opt/python/result.py
Normal file
1006
ortools/math_opt/python/result.py
Normal file
File diff suppressed because it is too large
Load Diff
1047
ortools/math_opt/python/result_test.py
Normal file
1047
ortools/math_opt/python/result_test.py
Normal file
File diff suppressed because it is too large
Load Diff
411
ortools/math_opt/python/solution.py
Normal file
411
ortools/math_opt/python/solution.py
Normal 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
|
||||
313
ortools/math_opt/python/solution_test.py
Normal file
313
ortools/math_opt/python/solution_test.py
Normal 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()
|
||||
270
ortools/math_opt/python/solve.py
Normal file
270
ortools/math_opt/python/solve.py
Normal 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()
|
||||
93
ortools/math_opt/python/solve_gurobi_test.py
Normal file
93
ortools/math_opt/python/solve_gurobi_test.py
Normal 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()
|
||||
558
ortools/math_opt/python/solve_test.py
Normal file
558
ortools/math_opt/python/solve_test.py
Normal 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()
|
||||
124
ortools/math_opt/python/sparse_containers.py
Normal file
124
ortools/math_opt/python/sparse_containers.py
Normal 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]
|
||||
175
ortools/math_opt/python/sparse_containers_test.py
Normal file
175
ortools/math_opt/python/sparse_containers_test.py
Normal 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()
|
||||
172
ortools/math_opt/python/statistics.py
Normal file
172
ortools/math_opt/python/statistics.py
Normal 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()
|
||||
),
|
||||
)
|
||||
197
ortools/math_opt/python/statistics_test.py
Normal file
197
ortools/math_opt/python/statistics_test.py
Normal 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()
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
40
ortools/math_opt/samples/cpp/CMakeLists.txt
Normal file
40
ortools/math_opt/samples/cpp/CMakeLists.txt
Normal 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()
|
||||
115
ortools/math_opt/samples/python/BUILD.bazel
Normal file
115
ortools/math_opt/samples/python/BUILD.bazel
Normal 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",
|
||||
],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
57
ortools/math_opt/samples/python/basic_example.py
Normal file
57
ortools/math_opt/samples/python/basic_example.py
Normal 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)
|
||||
40
ortools/math_opt/samples/python/basic_example_test.py
Normal file
40
ortools/math_opt/samples/python/basic_example_test.py
Normal 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()
|
||||
306
ortools/math_opt/samples/python/cutting_stock.py
Normal file
306
ortools/math_opt/samples/python/cutting_stock.py
Normal 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)
|
||||
28
ortools/math_opt/samples/python/cutting_stock_test.py
Normal file
28
ortools/math_opt/samples/python/cutting_stock_test.py
Normal 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()
|
||||
603
ortools/math_opt/samples/python/facility_lp_benders.py
Normal file
603
ortools/math_opt/samples/python/facility_lp_benders.py
Normal 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)
|
||||
34
ortools/math_opt/samples/python/facility_lp_benders_test.py
Normal file
34
ortools/math_opt/samples/python/facility_lp_benders_test.py
Normal 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()
|
||||
67
ortools/math_opt/samples/python/integer_programming.py
Normal file
67
ortools/math_opt/samples/python/integer_programming.py
Normal 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)
|
||||
37
ortools/math_opt/samples/python/integer_programming_test.py
Normal file
37
ortools/math_opt/samples/python/integer_programming_test.py
Normal 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()
|
||||
62
ortools/math_opt/samples/python/linear_programming.py
Normal file
62
ortools/math_opt/samples/python/linear_programming.py
Normal 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)
|
||||
36
ortools/math_opt/samples/python/linear_programming_test.py
Normal file
36
ortools/math_opt/samples/python/linear_programming_test.py
Normal 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()
|
||||
159
ortools/math_opt/samples/python/linear_regression.py
Normal file
159
ortools/math_opt/samples/python/linear_regression.py
Normal 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)
|
||||
39
ortools/math_opt/samples/python/linear_regression_test.py
Normal file
39
ortools/math_opt/samples/python/linear_regression_test.py
Normal 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()
|
||||
82
ortools/math_opt/samples/python/remote_solve.py
Normal file
82
ortools/math_opt/samples/python/remote_solve.py
Normal 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)
|
||||
39
ortools/math_opt/samples/python/remote_solve_test.py
Normal file
39
ortools/math_opt/samples/python/remote_solve_test.py
Normal 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()
|
||||
242
ortools/math_opt/samples/python/time_indexed_scheduling.py
Normal file
242
ortools/math_opt/samples/python/time_indexed_scheduling.py
Normal 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)
|
||||
@@ -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()
|
||||
255
ortools/math_opt/samples/python/tsp.py
Normal file
255
ortools/math_opt/samples/python/tsp.py
Normal 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)
|
||||
46
ortools/math_opt/samples/python/tsp_test.py
Normal file
46
ortools/math_opt/samples/python/tsp_test.py
Normal 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()
|
||||
Reference in New Issue
Block a user