math_opt: Export from google3

This commit is contained in:
Corentin Le Molgat
2024-11-12 13:55:16 +01:00
parent 8a5976b99f
commit cac5698cd2
17 changed files with 926 additions and 291 deletions

View File

@@ -28,6 +28,7 @@ py_library(
":errors",
":expressions",
":hash_model_storage",
":init_arguments",
":message_callback",
":model",
":model_parameters",
@@ -160,6 +161,7 @@ py_library(
":callback",
":compute_infeasible_subsystem_result",
":errors",
":init_arguments",
":message_callback",
":model",
":model_parameters",
@@ -210,3 +212,12 @@ py_library(
srcs = ["errors.py"],
deps = ["//ortools/math_opt:rpc_py_pb2"],
)
py_library(
name = "init_arguments",
srcs = ["init_arguments.py"],
deps = [
"//ortools/math_opt:parameters_py_pb2",
"//ortools/math_opt/solvers:gurobi_py_pb2",
],
)

View File

@@ -0,0 +1,180 @@
# Copyright 2010-2024 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 instantiation of the underlying solver."""
import dataclasses
from typing import Optional
from ortools.math_opt import parameters_pb2
from ortools.math_opt.solvers import gurobi_pb2
@dataclasses.dataclass
class StreamableGScipInitArguments:
"""Streamable GScip specific parameters for solver instantiation."""
@dataclasses.dataclass(frozen=True)
class GurobiISVKey:
"""The Gurobi ISV key, an alternative to license files.
Contact Gurobi for details.
Attributes:
name: A string, typically a company/organization.
application_name: A string, typically a project.
expiration: An int, a value of 0 indicates no expiration.
key: A string, the secret.
"""
name: str = ""
application_name: str = ""
expiration: int = 0
key: str = ""
def to_proto(self) -> gurobi_pb2.GurobiInitializerProto.ISVKey:
"""Returns a protocol buffer equivalent of this."""
return gurobi_pb2.GurobiInitializerProto.ISVKey(
name=self.name,
application_name=self.application_name,
expiration=self.expiration,
key=self.key,
)
def gurobi_isv_key_from_proto(
proto: gurobi_pb2.GurobiInitializerProto.ISVKey,
) -> GurobiISVKey:
"""Returns an equivalent GurobiISVKey to the input proto."""
return GurobiISVKey(
name=proto.name,
application_name=proto.application_name,
expiration=proto.expiration,
key=proto.key,
)
@dataclasses.dataclass
class StreamableGurobiInitArguments:
"""Streamable Gurobi specific parameters for solver instantiation."""
isv_key: Optional[GurobiISVKey] = None
def to_proto(self) -> gurobi_pb2.GurobiInitializerProto:
"""Returns a protocol buffer equivalent of this."""
return gurobi_pb2.GurobiInitializerProto(
isv_key=self.isv_key.to_proto() if self.isv_key else None
)
def streamable_gurobi_init_arguments_from_proto(
proto: gurobi_pb2.GurobiInitializerProto,
) -> StreamableGurobiInitArguments:
"""Returns an equivalent StreamableGurobiInitArguments to the input proto."""
result = StreamableGurobiInitArguments()
if proto.HasField("isv_key"):
result.isv_key = gurobi_isv_key_from_proto(proto.isv_key)
return result
@dataclasses.dataclass
class StreamableGlopInitArguments:
"""Streamable Glop specific parameters for solver instantiation."""
@dataclasses.dataclass
class StreamableCpSatInitArguments:
"""Streamable CP-SAT specific parameters for solver instantiation."""
@dataclasses.dataclass
class StreamablePdlpInitArguments:
"""Streamable Pdlp specific parameters for solver instantiation."""
@dataclasses.dataclass
class StreamableGlpkInitArguments:
"""Streamable GLPK specific parameters for solver instantiation."""
@dataclasses.dataclass
class StreamableOsqpInitArguments:
"""Streamable OSQP specific parameters for solver instantiation."""
@dataclasses.dataclass
class StreamableEcosInitArguments:
"""Streamable Ecos specific parameters for solver instantiation."""
@dataclasses.dataclass
class StreamableScsInitArguments:
"""Streamable Scs specific parameters for solver instantiation."""
@dataclasses.dataclass
class StreamableHighsInitArguments:
"""Streamable Highs specific parameters for solver instantiation."""
@dataclasses.dataclass
class StreamableSantoriniInitArguments:
"""Streamable Santorini specific parameters for solver instantiation."""
@dataclasses.dataclass
class StreamableSolverInitArguments:
"""Solver initialization parameters that can be sent to another process.
Attributes:
gscip: Initialization parameters specific to GScip.
gurobi: Initialization parameters specific to Gurobi.
glop: Initialization parameters specific to GLOP.
cp_sat: Initialization parameters specific to CP-SAT.
pdlp: Initialization parameters specific to PDLP.
glpk: Initialization parameters specific to GLPK.
osqp: Initialization parameters specific to OSQP.
ecos: Initialization parameters specific to ECOS.
scs: Initialization parameters specific to SCS.
highs: Initialization parameters specific to HiGHS.
santorini: Initialization parameters specific to Santorini.
"""
gscip: Optional[StreamableGScipInitArguments] = None
gurobi: Optional[StreamableGurobiInitArguments] = None
glop: Optional[StreamableGlopInitArguments] = None
cp_sat: Optional[StreamableCpSatInitArguments] = None
pdlp: Optional[StreamablePdlpInitArguments] = None
glpk: Optional[StreamableGlpkInitArguments] = None
osqp: Optional[StreamableOsqpInitArguments] = None
ecos: Optional[StreamableEcosInitArguments] = None
scs: Optional[StreamableScsInitArguments] = None
highs: Optional[StreamableHighsInitArguments] = None
santorini: Optional[StreamableSantoriniInitArguments] = None
def to_proto(self) -> parameters_pb2.SolverInitializerProto:
"""Returns a protocol buffer equivalent of this."""
return parameters_pb2.SolverInitializerProto(
gurobi=self.gurobi.to_proto() if self.gurobi else None
)
def streamable_solver_init_arguments_from_proto(
proto: parameters_pb2.SolverInitializerProto,
) -> StreamableSolverInitArguments:
"""Returns an equivalent StreamableSolverInitArguments to the input proto."""
result = StreamableSolverInitArguments()
if proto.HasField("gurobi"):
result.gurobi = streamable_gurobi_init_arguments_from_proto(proto.gurobi)
return result

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
# Copyright 2010-2024 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.
from absl.testing import absltest
from ortools.math_opt import parameters_pb2
from ortools.math_opt.python import init_arguments
from ortools.math_opt.python.testing import compare_proto
from ortools.math_opt.solvers import gurobi_pb2
class GurobiISVKeyTest(absltest.TestCase, compare_proto.MathOptProtoAssertions):
def test_proto_conversions(self) -> None:
isv = init_arguments.GurobiISVKey(
name="cat", application_name="hat", expiration=4, key="bat"
)
proto_isv = gurobi_pb2.GurobiInitializerProto.ISVKey(
name="cat", application_name="hat", expiration=4, key="bat"
)
self.assert_protos_equiv(isv.to_proto(), proto_isv)
self.assertEqual(init_arguments.gurobi_isv_key_from_proto(proto_isv), isv)
class StreamableGurobiInitArgumentsTest(
absltest.TestCase, compare_proto.MathOptProtoAssertions
):
def test_proto_conversions_isv_key_set(self) -> None:
init = init_arguments.StreamableGurobiInitArguments(
isv_key=init_arguments.GurobiISVKey(
name="cat", application_name="hat", expiration=4, key="bat"
)
)
proto_init = gurobi_pb2.GurobiInitializerProto(
isv_key=gurobi_pb2.GurobiInitializerProto.ISVKey(
name="cat", application_name="hat", expiration=4, key="bat"
)
)
self.assert_protos_equiv(init.to_proto(), proto_init)
self.assertEqual(
init_arguments.streamable_gurobi_init_arguments_from_proto(proto_init),
init,
)
def test_proto_conversions_isv_key_not_set(self) -> None:
init = init_arguments.StreamableGurobiInitArguments()
proto_init = gurobi_pb2.GurobiInitializerProto()
self.assert_protos_equiv(init.to_proto(), proto_init)
self.assertEqual(
init_arguments.streamable_gurobi_init_arguments_from_proto(proto_init),
init,
)
class StreamableSolverInitArgumentsTest(
absltest.TestCase, compare_proto.MathOptProtoAssertions
):
def test_proto_conversions_gurobi_set(self) -> None:
init = init_arguments.StreamableSolverInitArguments(
gurobi=init_arguments.StreamableGurobiInitArguments(
isv_key=init_arguments.GurobiISVKey(
name="cat", application_name="hat", expiration=4, key="bat"
)
)
)
proto_init = parameters_pb2.SolverInitializerProto(
gurobi=gurobi_pb2.GurobiInitializerProto(
isv_key=gurobi_pb2.GurobiInitializerProto.ISVKey(
name="cat", application_name="hat", expiration=4, key="bat"
)
)
)
self.assert_protos_equiv(init.to_proto(), proto_init)
self.assertEqual(
init_arguments.streamable_solver_init_arguments_from_proto(proto_init),
init,
)
def test_proto_conversions_gurobi_not_set(self) -> None:
init = init_arguments.StreamableSolverInitArguments()
proto_init = parameters_pb2.SolverInitializerProto()
self.assert_protos_equiv(init.to_proto(), proto_init)
self.assertEqual(
init_arguments.streamable_solver_init_arguments_from_proto(proto_init),
init,
)
if __name__ == "__main__":
absltest.main()

View File

@@ -65,6 +65,26 @@ from ortools.math_opt.python.errors import status_proto_to_exception
from ortools.math_opt.python.expressions import evaluate_expression
from ortools.math_opt.python.expressions import fast_sum
from ortools.math_opt.python.hash_model_storage import HashModelStorage
from ortools.math_opt.python.init_arguments import gurobi_isv_key_from_proto
from ortools.math_opt.python.init_arguments import GurobiISVKey
from ortools.math_opt.python.init_arguments import (
streamable_gurobi_init_arguments_from_proto,
)
from ortools.math_opt.python.init_arguments import (
streamable_solver_init_arguments_from_proto,
)
from ortools.math_opt.python.init_arguments import StreamableCpSatInitArguments
from ortools.math_opt.python.init_arguments import StreamableEcosInitArguments
from ortools.math_opt.python.init_arguments import StreamableGlopInitArguments
from ortools.math_opt.python.init_arguments import StreamableGlpkInitArguments
from ortools.math_opt.python.init_arguments import StreamableGScipInitArguments
from ortools.math_opt.python.init_arguments import StreamableGurobiInitArguments
from ortools.math_opt.python.init_arguments import StreamableHighsInitArguments
from ortools.math_opt.python.init_arguments import StreamableOsqpInitArguments
from ortools.math_opt.python.init_arguments import StreamablePdlpInitArguments
from ortools.math_opt.python.init_arguments import StreamableSantoriniInitArguments
from ortools.math_opt.python.init_arguments import StreamableScsInitArguments
from ortools.math_opt.python.init_arguments import StreamableSolverInitArguments
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

View File

@@ -22,6 +22,7 @@ from absl.testing import absltest
from ortools.math_opt.python import callback
from ortools.math_opt.python import expressions
from ortools.math_opt.python import hash_model_storage
from ortools.math_opt.python import init_arguments
from ortools.math_opt.python import mathopt
from ortools.math_opt.python import message_callback
from ortools.math_opt.python import model
@@ -48,6 +49,7 @@ _MODULES_TO_CHECK: List[types.ModuleType] = [
callback,
expressions,
hash_model_storage,
init_arguments,
message_callback,
model,
model_parameters,

View File

@@ -325,6 +325,18 @@ class Termination:
problem_status: ProblemStatus = ProblemStatus()
objective_bounds: ObjectiveBounds = ObjectiveBounds()
def to_proto(self) -> result_pb2.TerminationProto:
"""Returns an equivalent protocol buffer to this Termination."""
return result_pb2.TerminationProto(
reason=self.reason.value,
limit=(
result_pb2.LIMIT_UNSPECIFIED if self.limit is None else self.limit.value
),
detail=self.detail,
problem_status=self.problem_status.to_proto(),
objective_bounds=self.objective_bounds.to_proto(),
)
def parse_termination(
termination_proto: result_pb2.TerminationProto,
@@ -930,6 +942,39 @@ class SolveResult:
f"variable_status: {type(variables).__name__!r}"
)
def to_proto(self) -> result_pb2.SolveResultProto:
"""Returns an equivalent protocol buffer for a SolveResult."""
proto = result_pb2.SolveResultProto(
termination=self.termination.to_proto(),
solutions=[s.to_proto() for s in self.solutions],
primal_rays=[r.to_proto() for r in self.primal_rays],
dual_rays=[r.to_proto() for r in self.dual_rays],
solve_stats=self.solve_stats.to_proto(),
)
# Ensure that at most solver has solver specific output.
existing_solver_specific_output = None
def has_solver_specific_output(solver_name: str) -> None:
nonlocal existing_solver_specific_output
if existing_solver_specific_output is not None:
raise ValueError(
"found solver specific output for both"
f" {existing_solver_specific_output} and {solver_name}"
)
existing_solver_specific_output = solver_name
if self.gscip_specific_output is not None:
has_solver_specific_output("gscip")
proto.gscip_output.CopyFrom(self.gscip_specific_output)
if self.osqp_specific_output is not None:
has_solver_specific_output("osqp")
proto.osqp_output.CopyFrom(self.osqp_specific_output)
if self.pdlp_specific_output is not None:
has_solver_specific_output("pdlp")
proto.pdlp_output.CopyFrom(self.pdlp_specific_output)
return proto
def _get_problem_status(
result_proto: result_pb2.SolveResultProto,

View File

@@ -16,6 +16,8 @@ import datetime
import math
from absl.testing import absltest
from ortools.pdlp import solve_log_pb2
from ortools.gscip import gscip_pb2
from ortools.math_opt import result_pb2
from ortools.math_opt import solution_pb2
from ortools.math_opt import sparse_containers_pb2
@@ -23,9 +25,10 @@ from ortools.math_opt.python import model
from ortools.math_opt.python import result
from ortools.math_opt.python import solution
from ortools.math_opt.python.testing import compare_proto
from ortools.math_opt.solvers import osqp_pb2
class ParseTerminationReason(compare_proto.MathOptProtoAssertions, absltest.TestCase):
class TerminationTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
def test_termination_unspecified(self) -> None:
termination_proto = result_pb2.TerminationProto(
@@ -54,7 +57,19 @@ class ParseTerminationReason(compare_proto.MathOptProtoAssertions, absltest.Test
):
result.parse_termination(termination_proto)
def test_termination_ok(self) -> None:
def test_termination_ok_proto_round_trip(self) -> None:
termination = result.Termination(
reason=result.TerminationReason.NO_SOLUTION_FOUND,
limit=result.Limit.OTHER,
detail="detail",
problem_status=result.ProblemStatus(
primal_status=result.FeasibilityStatus.FEASIBLE,
dual_status=result.FeasibilityStatus.INFEASIBLE,
primal_or_dual_infeasible=False,
),
objective_bounds=result.ObjectiveBounds(primal_bound=10, dual_bound=20),
)
termination_proto = result_pb2.TerminationProto(
reason=result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND,
limit=result_pb2.LIMIT_OTHER,
@@ -68,22 +83,12 @@ class ParseTerminationReason(compare_proto.MathOptProtoAssertions, absltest.Test
primal_bound=10, dual_bound=20
),
)
termination = result.parse_termination(termination_proto)
self.assertEqual(termination.reason, result.TerminationReason.NO_SOLUTION_FOUND)
self.assertEqual(termination.limit, result.Limit.OTHER)
self.assertEqual(termination.detail, "detail")
self.assertEqual(
termination.problem_status,
result.ProblemStatus(
primal_status=result.FeasibilityStatus.FEASIBLE,
dual_status=result.FeasibilityStatus.INFEASIBLE,
primal_or_dual_infeasible=False,
),
)
self.assertEqual(
termination.objective_bounds,
result.ObjectiveBounds(primal_bound=10, dual_bound=20),
)
# Test proto-> Termination
self.assertEqual(result.parse_termination(termination_proto), termination)
# Test Termination -> proto
self.assert_protos_equiv(termination.to_proto(), termination_proto)
class ParseProblemStatus(compare_proto.MathOptProtoAssertions, absltest.TestCase):
@@ -617,83 +622,105 @@ def _make_undetermined_result_proto() -> result_pb2.SolveResultProto:
primal_bound=math.inf,
dual_bound=-math.inf,
),
),
solutions=[
solution_pb2.SolutionProto(
primal_solution=solution_pb2.PrimalSolutionProto(
objective_value=2.0,
variable_values=sparse_containers_pb2.SparseDoubleVectorProto(
ids=[0], values=[1.0]
),
feasibility_status=solution_pb2.SOLUTION_STATUS_UNDETERMINED,
)
)
],
)
)
proto.solve_stats.problem_status.primal_status = (
result_pb2.FEASIBILITY_STATUS_UNDETERMINED
)
proto.solve_stats.problem_status.dual_status = (
result_pb2.FEASIBILITY_STATUS_UNDETERMINED
)
proto.solve_stats.problem_status.primal_or_dual_infeasible = False
proto.solve_stats.best_primal_bound = math.inf
proto.solve_stats.best_dual_bound = -math.inf
proto.solve_stats.solve_time.FromTimedelta(datetime.timedelta(minutes=2))
return proto
def _make_undetermined_solve_result() -> result.SolveResult:
return result.SolveResult(
termination=result.Termination(
reason=result.TerminationReason.NO_SOLUTION_FOUND,
limit=result.Limit.TIME,
problem_status=result.ProblemStatus(
primal_status=result.FeasibilityStatus.UNDETERMINED,
dual_status=result.FeasibilityStatus.UNDETERMINED,
),
objective_bounds=result.ObjectiveBounds(
primal_bound=math.inf, dual_bound=-math.inf
),
),
solve_stats=result.SolveStats(solve_time=datetime.timedelta(minutes=2)),
)
class SolveResultTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
def test_solve_result_gscip_output(self) -> None:
mod = model.Model(name="test_model")
mod.add_binary_variable()
res = _make_undetermined_solve_result()
res.gscip_specific_output = gscip_pb2.GScipOutput(status_detail="gscip_detail")
proto = _make_undetermined_result_proto()
proto.gscip_output.status_detail = "gscip_detail"
res = result.parse_solve_result(proto, mod)
assert res.gscip_specific_output is not None
self.assertEqual("gscip_detail", res.gscip_specific_output.status_detail)
def test_solve_result_no_gscip_output(self) -> None:
mod = model.Model(name="test_model")
mod.add_binary_variable()
proto = _make_undetermined_result_proto()
res = result.parse_solve_result(proto, mod)
self.assertIsNone(res.gscip_specific_output)
# proto -> result
actual_res = result.parse_solve_result(proto, mod)
self.assertIsNotNone(actual_res.gscip_specific_output)
assert actual_res.gscip_specific_output is not None
self.assertEqual("gscip_detail", actual_res.gscip_specific_output.status_detail)
self.assertIsNone(actual_res.pdlp_specific_output)
self.assertIsNone(actual_res.osqp_specific_output)
# result -> proto
self.assert_protos_equiv(res.to_proto(), proto)
def test_solve_result_osqp_output(self) -> None:
mod = model.Model(name="test_model")
mod.add_binary_variable()
proto = _make_undetermined_result_proto()
proto.osqp_output.initialized_underlying_solver = False
res = result.parse_solve_result(proto, mod)
assert res.osqp_specific_output is not None
self.assertFalse(res.osqp_specific_output.initialized_underlying_solver)
res = _make_undetermined_solve_result()
res.osqp_specific_output = osqp_pb2.OsqpOutput(
initialized_underlying_solver=True
)
def test_solve_result_no_osqp_output(self) -> None:
mod = model.Model(name="test_model")
mod.add_binary_variable()
proto = _make_undetermined_result_proto()
res = result.parse_solve_result(proto, mod)
self.assertIsNone(res.osqp_specific_output)
proto.osqp_output.initialized_underlying_solver = True
# proto -> result
actual_res = result.parse_solve_result(proto, mod)
self.assertIsNotNone(actual_res.osqp_specific_output)
assert actual_res.osqp_specific_output is not None
self.assertTrue(actual_res.osqp_specific_output.initialized_underlying_solver)
self.assertIsNone(actual_res.pdlp_specific_output)
self.assertIsNone(actual_res.gscip_specific_output)
# result -> proto
self.assert_protos_equiv(res.to_proto(), proto)
def test_solve_result_pdlp_output(self) -> None:
mod = model.Model(name="test_model")
mod.add_binary_variable()
proto = _make_undetermined_result_proto()
proto.pdlp_output.convergence_information.corrected_dual_objective = 2.0
res = result.parse_solve_result(proto, mod)
assert res.pdlp_specific_output is not None
self.assertEqual(
res.pdlp_specific_output.convergence_information.corrected_dual_objective,
2.0,
res = _make_undetermined_solve_result()
res.pdlp_specific_output = result_pb2.SolveResultProto.PdlpOutput(
convergence_information=solve_log_pb2.ConvergenceInformation(
primal_objective=1.0
)
)
def test_solve_result_no_pdlp_output(self) -> None:
mod = model.Model(name="test_model")
mod.add_binary_variable()
proto = _make_undetermined_result_proto()
res = result.parse_solve_result(proto, mod)
self.assertIsNone(res.pdlp_specific_output)
proto.pdlp_output.convergence_information.primal_objective = 1.0
# proto -> result
actual_res = result.parse_solve_result(proto, mod)
self.assertIsNotNone(actual_res.pdlp_specific_output)
assert actual_res.pdlp_specific_output is not None
self.assertEqual(
actual_res.pdlp_specific_output.convergence_information.primal_objective,
1.0,
)
self.assertIsNone(actual_res.osqp_specific_output)
self.assertIsNone(actual_res.gscip_specific_output)
# result -> proto
self.assert_protos_equiv(res.to_proto(), proto)
def test_multiple_solver_specific_outputs_error(self) -> None:
res = _make_undetermined_solve_result()
res.gscip_specific_output = gscip_pb2.GScipOutput(status_detail="gscip_detail")
res.osqp_specific_output = osqp_pb2.OsqpOutput(
initialized_underlying_solver=False
)
with self.assertRaisesRegex(ValueError, "solver specific output"):
res.to_proto()
def test_solve_result_from_proto_missing_bounds_in_termination(
self,
@@ -1048,6 +1075,78 @@ class SolveResultTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
self.assertEqual(20, res.termination.objective_bounds.dual_bound)
self.assertIsNone(res.gscip_specific_output)
def test_to_proto_round_trip(self) -> None:
mod = model.Model(name="test_model")
x = mod.add_binary_variable(name="x")
c = mod.add_linear_constraint(lb=0.0, ub=1.0, name="c")
s = solution.Solution(
primal_solution=solution.PrimalSolution(
variable_values={x: 1.0},
objective_value=2.0,
feasibility_status=solution.SolutionStatus.FEASIBLE,
)
)
r = result.SolveResult(
termination=result.Termination(
reason=result.TerminationReason.FEASIBLE,
limit=result.Limit.TIME,
problem_status=result.ProblemStatus(
primal_status=result.FeasibilityStatus.FEASIBLE,
dual_status=result.FeasibilityStatus.UNDETERMINED,
),
),
solve_stats=result.SolveStats(
node_count=3, solve_time=datetime.timedelta(seconds=4)
),
solutions=[s],
primal_rays=[solution.PrimalRay(variable_values={x: 4.0})],
dual_rays=[solution.DualRay(reduced_costs={x: 5.0}, dual_values={c: 6.0})],
)
s_proto = solution_pb2.SolutionProto(
primal_solution=solution_pb2.PrimalSolutionProto(
objective_value=2.0,
feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE,
variable_values=sparse_containers_pb2.SparseDoubleVectorProto(
ids=[0], values=[1.0]
),
)
)
r_proto = result_pb2.SolveResultProto(
termination=result_pb2.TerminationProto(
reason=result_pb2.TERMINATION_REASON_FEASIBLE,
limit=result_pb2.LIMIT_TIME,
problem_status=result_pb2.ProblemStatusProto(
primal_status=result_pb2.FEASIBILITY_STATUS_FEASIBLE,
dual_status=result_pb2.FEASIBILITY_STATUS_UNDETERMINED,
),
),
solve_stats=result_pb2.SolveStatsProto(node_count=3),
solutions=[s_proto],
primal_rays=[
solution_pb2.PrimalRayProto(
variable_values=sparse_containers_pb2.SparseDoubleVectorProto(
ids=[0], values=[4.0]
)
)
],
dual_rays=[
solution_pb2.DualRayProto(
reduced_costs=sparse_containers_pb2.SparseDoubleVectorProto(
ids=[0], values=[5.0]
),
dual_values=sparse_containers_pb2.SparseDoubleVectorProto(
ids=[0], values=[6.0]
),
)
],
)
r_proto.solve_stats.solve_time.FromTimedelta(datetime.timedelta(seconds=4))
self.assert_protos_equiv(r.to_proto(), r_proto)
self.assertEqual(result.parse_solve_result(r_proto, mod), r)
if __name__ == "__main__":
absltest.main()

View File

@@ -164,6 +164,14 @@ class PrimalRay:
default_factory=dict
)
def to_proto(self) -> solution_pb2.PrimalRayProto:
"""Returns an equivalent proto to this PrimalRay."""
return solution_pb2.PrimalRayProto(
variable_values=sparse_containers.to_sparse_double_vector_proto(
self.variable_values
)
)
def parse_primal_ray(proto: solution_pb2.PrimalRayProto, mod: model.Model) -> PrimalRay:
"""Returns an equivalent PrimalRay from the input proto."""
@@ -278,6 +286,17 @@ class DualRay:
)
reduced_costs: Dict[model.Variable, float] = dataclasses.field(default_factory=dict)
def to_proto(self) -> solution_pb2.DualRayProto:
"""Returns an equivalent proto to this PrimalRay."""
return solution_pb2.DualRayProto(
dual_values=sparse_containers.to_sparse_double_vector_proto(
self.dual_values
),
reduced_costs=sparse_containers.to_sparse_double_vector_proto(
self.reduced_costs
),
)
def parse_dual_ray(proto: solution_pb2.DualRayProto, mod: model.Model) -> DualRay:
"""Returns an equivalent DualRay from the input proto."""

View File

@@ -76,17 +76,24 @@ class ParsePrimalSolutionTest(compare_proto.MathOptProtoAssertions, absltest.Tes
solution.parse_primal_solution(proto, mod)
class ParsePrimalRayTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
class PrimalRayTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
def test_parse(self) -> None:
def test_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")
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)
ray = solution.PrimalRay(variable_values={x: 1.0, y: 1.0})
ray_proto = solution_pb2.PrimalRayProto()
ray_proto.variable_values.ids[:] = [0, 1]
ray_proto.variable_values.values[:] = [1.0, 1.0]
# Test proto -> model
parsed_ray = solution.parse_primal_ray(ray_proto, mod)
self.assertDictEqual({x: 1.0, y: 1.0}, parsed_ray.variable_values)
# Test model -> proto
exported_ray = ray.to_proto()
self.assert_protos_equiv(exported_ray, ray_proto)
class ParseDualSolutionTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
@@ -151,22 +158,33 @@ class ParseDualSolutionTest(compare_proto.MathOptProtoAssertions, absltest.TestC
solution.parse_dual_solution(proto, mod)
class ParseDualRayTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
class DualRayTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
def test_parse(self) -> None:
def test_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")
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)
dual_ray = solution.DualRay(
dual_values={c: 0.0, d: 1.0}, reduced_costs={x: 10.0, y: 0.0}
)
dual_ray_proto = solution_pb2.DualRayProto()
dual_ray_proto.dual_values.ids[:] = [0, 1]
dual_ray_proto.dual_values.values[:] = [0.0, 1.0]
dual_ray_proto.reduced_costs.ids[:] = [0, 1]
dual_ray_proto.reduced_costs.values[:] = [10.0, 0.0]
# Test proto -> dual ray
parsed_ray = solution.parse_dual_ray(dual_ray_proto, mod)
self.assertDictEqual(dual_ray.reduced_costs, parsed_ray.reduced_costs)
self.assertDictEqual(dual_ray.dual_values, parsed_ray.dual_values)
# Test dual ray -> proto
exported_proto = dual_ray.to_proto()
self.assert_protos_equiv(exported_proto, dual_ray_proto)
class BasisTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):

View File

@@ -21,6 +21,7 @@ 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 errors
from ortools.math_opt.python import init_arguments
from ortools.math_opt.python import message_callback
from ortools.math_opt.python import model
from ortools.math_opt.python import model_parameters
@@ -40,6 +41,7 @@ def solve(
msg_cb: Optional[message_callback.SolveMessageCallback] = None,
callback_reg: Optional[callback.CallbackRegistration] = None,
cb: Optional[SolveCallback] = None,
streamable_init_args: Optional[init_arguments.StreamableSolverInitArguments] = None,
) -> result.SolveResult:
"""Solves an optimization model.
@@ -56,6 +58,7 @@ def solve(
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.
streamable_init_args: Configuration for initializing the underlying solver.
Returns:
A SolveResult containing the termination reason, solution(s) and stats.
@@ -68,6 +71,9 @@ def solve(
params = params or parameters.SolveParameters()
model_params = model_params or model_parameters.ModelSolveParameters()
callback_reg = callback_reg or callback.CallbackRegistration()
streamable_init_args = (
streamable_init_args or init_arguments.StreamableSolverInitArguments()
)
model_proto = opt_model.export_model()
proto_cb = None
if cb is not None:
@@ -79,7 +85,7 @@ def solve(
proto_result = solver.solve(
model_proto,
solver_type.value,
parameters_pb2.SolverInitializerProto(),
streamable_init_args.to_proto(),
params.to_proto(),
model_params.to_proto(),
msg_cb,
@@ -98,6 +104,7 @@ def compute_infeasible_subsystem(
*,
params: Optional[parameters.SolveParameters] = None,
msg_cb: Optional[message_callback.SolveMessageCallback] = None,
streamable_init_args: Optional[init_arguments.StreamableSolverInitArguments] = None,
) -> compute_infeasible_subsystem_result.ComputeInfeasibleSubsystemResult:
"""Computes an infeasible subsystem of the input model.
@@ -107,6 +114,7 @@ def compute_infeasible_subsystem(
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.
streamable_init_args: Configuration for initializing the underlying solver.
Returns:
An `ComputeInfeasibleSubsystemResult` where `feasibility` indicates if the
@@ -116,13 +124,16 @@ def compute_infeasible_subsystem(
RuntimeError: on invalid inputs or an internal solver error.
"""
params = params or parameters.SolveParameters()
streamable_init_args = (
streamable_init_args or init_arguments.StreamableSolverInitArguments()
)
model_proto = opt_model.export_model()
# Solve
try:
proto_result = solver.compute_infeasible_subsystem(
model_proto,
solver_type.value,
parameters_pb2.SolverInitializerProto(),
streamable_init_args.to_proto(),
params.to_proto(),
msg_cb,
None,
@@ -163,7 +174,18 @@ class IncrementalSolver:
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):
def __init__(
self,
opt_model: model.Model,
solver_type: parameters.SolverType,
*,
streamable_init_args: Optional[
init_arguments.StreamableSolverInitArguments
] = None,
):
streamable_init_args = (
streamable_init_args or init_arguments.StreamableSolverInitArguments()
)
self._model = opt_model
self._solver_type = solver_type
self._update_tracker = self._model.add_update_tracker()
@@ -171,7 +193,7 @@ class IncrementalSolver:
self._proto_solver = solver.new(
solver_type.value,
self._model.export_model(),
parameters_pb2.SolverInitializerProto(),
streamable_init_args.to_proto(),
)
except StatusNotOk as e:
raise _status_not_ok_to_exception(e) from None

View File

@@ -19,8 +19,10 @@ machine.
"""
from absl.testing import absltest
from ortools.gurobi.isv.secret import gurobi_test_isv_key
from ortools.math_opt.python import callback
from ortools.math_opt.python import compute_infeasible_subsystem_result
from ortools.math_opt.python import init_arguments
from ortools.math_opt.python import model
from ortools.math_opt.python import parameters
from ortools.math_opt.python import result
@@ -29,6 +31,18 @@ from ortools.math_opt.python import solve
_Bounds = compute_infeasible_subsystem_result.ModelSubsetBounds
_bad_isv_key = init_arguments.GurobiISVKey(
name="cat", application_name="hat", expiration=10, key="bat"
)
def _init_args(
gurobi_key: init_arguments.GurobiISVKey,
) -> init_arguments.StreamableSolverInitArguments:
return init_arguments.StreamableSolverInitArguments(
gurobi=init_arguments.StreamableGurobiInitArguments(isv_key=gurobi_key)
)
class SolveTest(absltest.TestCase):
@@ -89,6 +103,95 @@ class SolveTest(absltest.TestCase):
)
self.assertEmpty(iis.infeasible_subsystem.variable_integrality)
def test_solve_valid_isv_success(self):
mod = model.Model()
x = mod.add_binary_variable()
mod.maximize(x)
res = solve.solve(
mod,
parameters.SolverType.GUROBI,
streamable_init_args=_init_args(
gurobi_test_isv_key.google_test_isv_key_placeholder()
),
)
self.assertEqual(
res.termination.reason,
result.TerminationReason.OPTIMAL,
msg=res.termination,
)
self.assertAlmostEqual(1.0, res.termination.objective_bounds.primal_bound)
def test_solve_wrong_isv_error(self):
mod = model.Model()
x = mod.add_binary_variable()
mod.maximize(x)
with self.assertRaisesRegex(
ValueError, "failed to create Gurobi primary environment with ISV key"
):
solve.solve(
mod,
parameters.SolverType.GUROBI,
streamable_init_args=_init_args(_bad_isv_key),
)
def test_incremental_solver_valid_isv_success(self):
mod = model.Model()
x = mod.add_binary_variable()
mod.maximize(x)
s = solve.IncrementalSolver(
mod,
parameters.SolverType.GUROBI,
streamable_init_args=_init_args(
gurobi_test_isv_key.google_test_isv_key_placeholder()
),
)
res = s.solve()
self.assertEqual(
res.termination.reason,
result.TerminationReason.OPTIMAL,
msg=res.termination,
)
self.assertAlmostEqual(1.0, res.termination.objective_bounds.primal_bound)
def test_incremental_solver_wrong_isv_error(self):
mod = model.Model()
x = mod.add_binary_variable()
mod.maximize(x)
with self.assertRaisesRegex(
ValueError, "failed to create Gurobi primary environment with ISV key"
):
solve.IncrementalSolver(
mod,
parameters.SolverType.GUROBI,
streamable_init_args=_init_args(_bad_isv_key),
)
def test_compute_infeasible_subsystem_valid_isv_success(self):
mod = model.Model()
x = mod.add_binary_variable()
mod.add_linear_constraint(x >= 3.0)
res = solve.compute_infeasible_subsystem(
mod,
parameters.SolverType.GUROBI,
streamable_init_args=_init_args(
gurobi_test_isv_key.google_test_isv_key_placeholder()
),
)
self.assertEqual(res.feasibility, result.FeasibilityStatus.INFEASIBLE)
def test_compute_infeasible_subsystem_wrong_isv_error(self):
mod = model.Model()
x = mod.add_binary_variable()
mod.add_linear_constraint(x >= 3.0)
with self.assertRaisesRegex(
ValueError, "failed to create Gurobi primary environment with ISV key"
):
solve.compute_infeasible_subsystem(
mod,
parameters.SolverType.GUROBI,
streamable_init_args=_init_args(_bad_isv_key),
)
if __name__ == "__main__":
absltest.main()