math_opt: export from google3
* CMake has not been updated yet * bazel was compiling at least last week bazel: disable math opt facility_location.py missing some dependencies...
This commit is contained in:
@@ -27,21 +27,30 @@ py_library(
|
||||
":compute_infeasible_subsystem_result",
|
||||
":errors",
|
||||
":expressions",
|
||||
":hash_model_storage",
|
||||
":indicator_constraints",
|
||||
":init_arguments",
|
||||
":linear_constraints",
|
||||
":message_callback",
|
||||
":model",
|
||||
":model_parameters",
|
||||
":model_storage",
|
||||
":objectives",
|
||||
":parameters",
|
||||
":quadratic_constraints",
|
||||
":result",
|
||||
":solution",
|
||||
":solve",
|
||||
":solver_resources",
|
||||
":sparse_containers",
|
||||
":variables",
|
||||
],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "from_model",
|
||||
srcs = ["from_model.py"],
|
||||
deps = ["//ortools/math_opt/python/elemental"],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "model_storage",
|
||||
srcs = ["model_storage.py"],
|
||||
@@ -67,11 +76,29 @@ py_library(
|
||||
name = "model",
|
||||
srcs = ["model.py"],
|
||||
deps = [
|
||||
":hash_model_storage",
|
||||
":model_storage",
|
||||
requirement("immutabledict"),
|
||||
":from_model",
|
||||
":indicator_constraints",
|
||||
":linear_constraints",
|
||||
":normalized_inequality",
|
||||
":objectives",
|
||||
":quadratic_constraints",
|
||||
":variables",
|
||||
"//ortools/math_opt:model_py_pb2",
|
||||
"//ortools/math_opt:model_update_py_pb2",
|
||||
"//ortools/math_opt/elemental/python:cpp_elemental",
|
||||
"//ortools/math_opt/elemental/python:enums",
|
||||
"//ortools/math_opt/python/elemental",
|
||||
],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "linear_constraints",
|
||||
srcs = ["linear_constraints.py"],
|
||||
deps = [
|
||||
":from_model",
|
||||
":variables",
|
||||
"//ortools/math_opt/elemental/python:enums",
|
||||
"//ortools/math_opt/python/elemental",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -79,7 +106,10 @@ py_library(
|
||||
name = "sparse_containers",
|
||||
srcs = ["sparse_containers.py"],
|
||||
deps = [
|
||||
":linear_constraints",
|
||||
":model",
|
||||
":quadratic_constraints",
|
||||
":variables",
|
||||
"//ortools/math_opt:sparse_containers_py_pb2",
|
||||
],
|
||||
)
|
||||
@@ -88,24 +118,63 @@ py_library(
|
||||
name = "solution",
|
||||
srcs = ["solution.py"],
|
||||
deps = [
|
||||
":linear_constraints",
|
||||
":model",
|
||||
":objectives",
|
||||
":quadratic_constraints",
|
||||
":sparse_containers",
|
||||
":variables",
|
||||
"//ortools/math_opt:solution_py_pb2",
|
||||
],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "quadratic_constraints",
|
||||
srcs = ["quadratic_constraints.py"],
|
||||
deps = [
|
||||
":from_model",
|
||||
":variables",
|
||||
"//ortools/math_opt/elemental/python:enums",
|
||||
"//ortools/math_opt/python/elemental",
|
||||
],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "indicator_constraints",
|
||||
srcs = ["indicator_constraints.py"],
|
||||
deps = [
|
||||
":from_model",
|
||||
":variables",
|
||||
"//ortools/math_opt/elemental/python:enums",
|
||||
"//ortools/math_opt/python/elemental",
|
||||
],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "result",
|
||||
srcs = ["result.py"],
|
||||
deps = [
|
||||
":linear_constraints",
|
||||
":model",
|
||||
":solution",
|
||||
":variables",
|
||||
"//ortools/gscip:gscip_proto_py_pb2",
|
||||
"//ortools/math_opt:result_py_pb2",
|
||||
"//ortools/math_opt/solvers:osqp_py_pb2",
|
||||
],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "objectives",
|
||||
srcs = ["objectives.py"],
|
||||
deps = [
|
||||
":from_model",
|
||||
":variables",
|
||||
"//ortools/math_opt/elemental/python:enums",
|
||||
"//ortools/math_opt/python/elemental",
|
||||
],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "parameters",
|
||||
srcs = ["parameters.py"],
|
||||
@@ -126,9 +195,12 @@ py_library(
|
||||
name = "model_parameters",
|
||||
srcs = ["model_parameters.py"],
|
||||
deps = [
|
||||
":linear_constraints",
|
||||
":model",
|
||||
":objectives",
|
||||
":solution",
|
||||
":sparse_containers",
|
||||
":variables",
|
||||
"//ortools/math_opt:model_parameters_py_pb2",
|
||||
],
|
||||
)
|
||||
@@ -138,7 +210,9 @@ py_library(
|
||||
srcs = ["callback.py"],
|
||||
deps = [
|
||||
":model",
|
||||
":normalized_inequality",
|
||||
":sparse_containers",
|
||||
":variables",
|
||||
"//ortools/math_opt:callback_py_pb2",
|
||||
],
|
||||
)
|
||||
@@ -147,8 +221,10 @@ py_library(
|
||||
name = "compute_infeasible_subsystem_result",
|
||||
srcs = ["compute_infeasible_subsystem_result.py"],
|
||||
deps = [
|
||||
":linear_constraints",
|
||||
":model",
|
||||
":result",
|
||||
":variables",
|
||||
requirement("immutabledict"),
|
||||
"//ortools/math_opt:infeasible_subsystem_py_pb2",
|
||||
],
|
||||
@@ -176,7 +252,6 @@ py_library(
|
||||
py_library(
|
||||
name = "message_callback",
|
||||
srcs = ["message_callback.py"],
|
||||
srcs_version = "PY3",
|
||||
deps = [requirement("absl-py")],
|
||||
)
|
||||
|
||||
@@ -186,6 +261,32 @@ py_library(
|
||||
deps = [":model"],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "bounded_expressions",
|
||||
srcs = ["bounded_expressions.py"],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "variables",
|
||||
srcs = ["variables.py"],
|
||||
deps = [
|
||||
":bounded_expressions",
|
||||
":from_model",
|
||||
requirement("immutabledict"),
|
||||
"//ortools/math_opt/elemental/python:enums",
|
||||
"//ortools/math_opt/python/elemental",
|
||||
],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "normalized_inequality",
|
||||
srcs = ["normalized_inequality.py"],
|
||||
deps = [
|
||||
":bounded_expressions",
|
||||
":variables",
|
||||
],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "normalize",
|
||||
srcs = ["normalize.py"],
|
||||
@@ -197,7 +298,7 @@ py_library(
|
||||
name = "expressions",
|
||||
srcs = ["expressions.py"],
|
||||
deps = [
|
||||
":model",
|
||||
":variables",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ if(BUILD_TESTING)
|
||||
file(GLOB PYTHON_SRCS "*_test.py")
|
||||
list(FILTER PYTHON_SRCS EXCLUDE REGEX ".*/solve_gurobi_test.py") # need gurobi
|
||||
list(FILTER PYTHON_SRCS EXCLUDE REGEX ".*/solve_test.py") # need OSQP
|
||||
list(FILTER PYTHON_SRCS EXCLUDE REGEX ".*/normalize_test.py") # need google3 stuff
|
||||
list(FILTER PYTHON_SRCS EXCLUDE REGEX ".*/callback_test") # segfault
|
||||
list(FILTER PYTHON_SRCS EXCLUDE REGEX ".*/mathopt_test") # import test fail
|
||||
foreach(FILE_NAME IN LISTS PYTHON_SRCS)
|
||||
|
||||
182
ortools/math_opt/python/bounded_expressions.py
Normal file
182
ortools/math_opt/python/bounded_expressions.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# Copyright 2010-2025 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.
|
||||
|
||||
"""Bounded (above and below), upper bounded, and lower bounded expressions."""
|
||||
|
||||
import math
|
||||
from typing import Any, Generic, NoReturn, Optional, Type, TypeVar
|
||||
|
||||
_CHAINED_COMPARISON_MESSAGE = (
|
||||
"If you were trying to create a two-sided or "
|
||||
"ranged linear inequality of the form `lb <= "
|
||||
"expr <= ub`, try `(lb <= expr) <= ub` instead"
|
||||
)
|
||||
|
||||
|
||||
def _raise_binary_operator_type_error(
|
||||
operator: str,
|
||||
lhs: Type[Any],
|
||||
rhs: Type[Any],
|
||||
extra_message: Optional[str] = None,
|
||||
) -> NoReturn:
|
||||
"""Raises TypeError on unsupported operators."""
|
||||
message = (
|
||||
f"unsupported operand type(s) for {operator}: {lhs.__name__!r} and"
|
||||
f" {rhs.__name__!r}"
|
||||
)
|
||||
if extra_message is not None:
|
||||
message += "\n" + extra_message
|
||||
raise TypeError(message)
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class BoundedExpression(Generic[T]):
|
||||
"""An inequality of the form lower_bound <= expression <= upper_bound.
|
||||
|
||||
Where:
|
||||
* expression is a T, typically LinearBase or QuadraticBase.
|
||||
* lower_bound is a float.
|
||||
* upper_bound is a float.
|
||||
|
||||
Note: Because of limitations related to Python's handling of chained
|
||||
comparisons, bounded expressions cannot be directly created usign
|
||||
overloaded comparisons as in `lower_bound <= expression <= upper_bound`.
|
||||
One solution is to wrap one of the inequalities in parenthesis as in
|
||||
`(lower_bound <= expression) <= upper_bound`.
|
||||
"""
|
||||
|
||||
__slots__ = "_expression", "_lower_bound", "_upper_bound"
|
||||
|
||||
def __init__(self, lower_bound: float, expression: T, upper_bound: float) -> None:
|
||||
self._expression: T = expression
|
||||
self._lower_bound: float = lower_bound
|
||||
self._upper_bound: float = upper_bound
|
||||
|
||||
@property
|
||||
def expression(self) -> T:
|
||||
return self._expression
|
||||
|
||||
@property
|
||||
def lower_bound(self) -> float:
|
||||
return self._lower_bound
|
||||
|
||||
@property
|
||||
def upper_bound(self) -> float:
|
||||
return self._upper_bound
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
raise TypeError(
|
||||
"__bool__ is unsupported for BoundedExpression"
|
||||
+ "\n"
|
||||
+ _CHAINED_COMPARISON_MESSAGE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self._lower_bound} <= {self._expression!s} <= {self._upper_bound}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self._lower_bound} <= {self._expression!r} <= {self._upper_bound}"
|
||||
|
||||
|
||||
class UpperBoundedExpression(Generic[T]):
|
||||
"""An inequality of the form expression <= upper_bound.
|
||||
|
||||
Where:
|
||||
* expression is a T, and
|
||||
* upper_bound is a float
|
||||
"""
|
||||
|
||||
__slots__ = "_expression", "_upper_bound"
|
||||
|
||||
def __init__(self, expression: T, upper_bound: float) -> None:
|
||||
"""Operator overloading can be used instead: e.g. `x + y <= 2.0`."""
|
||||
self._expression: T = expression
|
||||
self._upper_bound: float = upper_bound
|
||||
|
||||
@property
|
||||
def expression(self) -> T:
|
||||
return self._expression
|
||||
|
||||
@property
|
||||
def lower_bound(self) -> float:
|
||||
return -math.inf
|
||||
|
||||
@property
|
||||
def upper_bound(self) -> float:
|
||||
return self._upper_bound
|
||||
|
||||
def __ge__(self, lhs: float) -> BoundedExpression[T]:
|
||||
if isinstance(lhs, (int, float)):
|
||||
return BoundedExpression[T](lhs, self.expression, self.upper_bound)
|
||||
_raise_binary_operator_type_error(">=", type(self), type(lhs))
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
raise TypeError(
|
||||
"__bool__ is unsupported for UpperBoundedExpression"
|
||||
+ "\n"
|
||||
+ _CHAINED_COMPARISON_MESSAGE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self._expression!s} <= {self._upper_bound}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self._expression!r} <= {self._upper_bound}"
|
||||
|
||||
|
||||
class LowerBoundedExpression(Generic[T]):
|
||||
"""An inequality of the form expression >= lower_bound.
|
||||
|
||||
Where:
|
||||
* expression is a linear expression, and
|
||||
* lower_bound is a float
|
||||
"""
|
||||
|
||||
__slots__ = "_expression", "_lower_bound"
|
||||
|
||||
def __init__(self, expression: T, lower_bound: float) -> None:
|
||||
"""Operator overloading can be used instead: e.g. `x + y >= 2.0`."""
|
||||
self._expression: T = expression
|
||||
self._lower_bound: float = lower_bound
|
||||
|
||||
@property
|
||||
def expression(self) -> T:
|
||||
return self._expression
|
||||
|
||||
@property
|
||||
def lower_bound(self) -> float:
|
||||
return self._lower_bound
|
||||
|
||||
@property
|
||||
def upper_bound(self) -> float:
|
||||
return math.inf
|
||||
|
||||
def __le__(self, rhs: float) -> BoundedExpression[T]:
|
||||
if isinstance(rhs, (int, float)):
|
||||
return BoundedExpression[T](self.lower_bound, self.expression, rhs)
|
||||
_raise_binary_operator_type_error("<=", type(self), type(rhs))
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
raise TypeError(
|
||||
"__bool__ is unsupported for LowerBoundedExpression"
|
||||
+ "\n"
|
||||
+ _CHAINED_COMPARISON_MESSAGE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self._expression!s} >= {self._lower_bound}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self._expression!r} >= {self._lower_bound}"
|
||||
83
ortools/math_opt/python/bounded_expressions_test.py
Normal file
83
ortools/math_opt/python/bounded_expressions_test.py
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2010-2025 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 absl.testing import absltest
|
||||
from ortools.math_opt.python import bounded_expressions
|
||||
|
||||
_BAD_BOOL_ERROR = "two-sided or ranged"
|
||||
|
||||
|
||||
class BoundedExpressionTest(absltest.TestCase):
|
||||
|
||||
def test_bounded_expression_read(self) -> None:
|
||||
b = bounded_expressions.BoundedExpression(
|
||||
lower_bound=-3.0, expression="e123", upper_bound=4.5
|
||||
)
|
||||
self.assertEqual(b.lower_bound, -3.0)
|
||||
self.assertEqual(b.upper_bound, 4.5)
|
||||
self.assertEqual(b.expression, "e123")
|
||||
self.assertEqual(str(b), "-3.0 <= e123 <= 4.5")
|
||||
self.assertEqual(repr(b), "-3.0 <= 'e123' <= 4.5")
|
||||
with self.assertRaisesRegex(TypeError, _BAD_BOOL_ERROR):
|
||||
bool(b)
|
||||
|
||||
def test_lower_bounded_expression_read(self) -> None:
|
||||
b = bounded_expressions.LowerBoundedExpression(
|
||||
lower_bound=-3.0, expression="e123"
|
||||
)
|
||||
self.assertEqual(b.lower_bound, -3.0)
|
||||
self.assertEqual(b.upper_bound, math.inf)
|
||||
self.assertEqual(b.expression, "e123")
|
||||
self.assertEqual(str(b), "e123 >= -3.0")
|
||||
self.assertEqual(repr(b), "'e123' >= -3.0")
|
||||
with self.assertRaisesRegex(TypeError, _BAD_BOOL_ERROR):
|
||||
bool(b)
|
||||
|
||||
def test_upper_bounded_expression_read(self) -> None:
|
||||
b = bounded_expressions.UpperBoundedExpression(
|
||||
expression="e123", upper_bound=4.5
|
||||
)
|
||||
self.assertEqual(b.lower_bound, -math.inf)
|
||||
self.assertEqual(b.upper_bound, 4.5)
|
||||
self.assertEqual(b.expression, "e123")
|
||||
self.assertEqual(str(b), "e123 <= 4.5")
|
||||
self.assertEqual(repr(b), "'e123' <= 4.5")
|
||||
with self.assertRaisesRegex(TypeError, _BAD_BOOL_ERROR):
|
||||
bool(b)
|
||||
|
||||
def test_lower_bounded_to_bounded(self) -> None:
|
||||
lb = bounded_expressions.LowerBoundedExpression(
|
||||
lower_bound=-3.0, expression="e123"
|
||||
)
|
||||
bounded = lb <= 4.5
|
||||
self.assertIsInstance(bounded, bounded_expressions.BoundedExpression)
|
||||
self.assertEqual(bounded.lower_bound, -3.0)
|
||||
self.assertEqual(bounded.upper_bound, 4.5)
|
||||
self.assertEqual(bounded.expression, "e123")
|
||||
|
||||
def test_upper_bounded_to_bounded(self) -> None:
|
||||
ub = bounded_expressions.UpperBoundedExpression(
|
||||
expression="e123", upper_bound=4.5
|
||||
)
|
||||
bounded = -3.0 <= ub
|
||||
self.assertIsInstance(bounded, bounded_expressions.BoundedExpression)
|
||||
self.assertEqual(bounded.lower_bound, -3.0)
|
||||
self.assertEqual(bounded.upper_bound, 4.5)
|
||||
self.assertEqual(bounded.expression, "e123")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
@@ -20,7 +20,9 @@ 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 normalized_inequality
|
||||
from ortools.math_opt.python import sparse_containers
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
|
||||
@enum.unique
|
||||
@@ -85,7 +87,7 @@ class CallbackData:
|
||||
"""
|
||||
|
||||
event: Event = Event.UNSPECIFIED
|
||||
solution: Optional[Dict[model.Variable, float]] = None
|
||||
solution: Optional[Dict[variables.Variable, float]] = None
|
||||
messages: List[str] = dataclasses.field(default_factory=list)
|
||||
runtime: datetime.timedelta = datetime.timedelta()
|
||||
presolve_stats: PresolveStats = dataclasses.field(default_factory=PresolveStats)
|
||||
@@ -197,7 +199,7 @@ class GeneratedConstraint:
|
||||
LP relaxation without cutting of integer solutions).
|
||||
"""
|
||||
|
||||
terms: Mapping[model.Variable, float] = dataclasses.field(default_factory=dict)
|
||||
terms: Mapping[variables.Variable, float] = dataclasses.field(default_factory=dict)
|
||||
lower_bound: float = -math.inf
|
||||
upper_bound: float = math.inf
|
||||
is_lazy: bool = False
|
||||
@@ -221,8 +223,18 @@ 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.
|
||||
terminate: When true it tells the solver to interrupt the solve as soon as
|
||||
possible.
|
||||
|
||||
It can be set from any event. This is equivalent to using a
|
||||
SolveInterrupter and triggering it from the callback.
|
||||
|
||||
Some solvers don't support interruption, in that case this is simply
|
||||
ignored and the solve terminates as usual. On top of that solvers may not
|
||||
immediately stop the solve. Thus the user should expect the callback to
|
||||
still be called after they set `terminate` to true in a previous
|
||||
call. Returning with `terminate` false after having previously returned
|
||||
true won't cancel the interruption.
|
||||
generated_constraints: Constraints to add to the model. For details, see
|
||||
GeneratedConstraint documentation.
|
||||
suggested_solutions: A list of solutions (or partially defined solutions) to
|
||||
@@ -235,17 +247,17 @@ class CallbackResult:
|
||||
generated_constraints: List[GeneratedConstraint] = dataclasses.field(
|
||||
default_factory=list
|
||||
)
|
||||
suggested_solutions: List[Mapping[model.Variable, float]] = dataclasses.field(
|
||||
suggested_solutions: List[Mapping[variables.Variable, float]] = dataclasses.field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
def add_generated_constraint(
|
||||
self,
|
||||
bounded_expr: Optional[Union[bool, model.BoundedLinearTypes]] = None,
|
||||
bounded_expr: Optional[Union[bool, variables.BoundedLinearTypes]] = None,
|
||||
*,
|
||||
lb: Optional[float] = None,
|
||||
ub: Optional[float] = None,
|
||||
expr: Optional[model.LinearTypes] = None,
|
||||
expr: Optional[variables.LinearTypes] = None,
|
||||
is_lazy: bool,
|
||||
) -> None:
|
||||
"""Adds a linear constraint to the list of generated constraints.
|
||||
@@ -296,25 +308,25 @@ class CallbackResult:
|
||||
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(
|
||||
norm_ineq = normalized_inequality.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,
|
||||
lower_bound=norm_ineq.lb,
|
||||
terms=norm_ineq.coefficients,
|
||||
upper_bound=norm_ineq.ub,
|
||||
is_lazy=is_lazy,
|
||||
)
|
||||
)
|
||||
|
||||
def add_lazy_constraint(
|
||||
self,
|
||||
bounded_expr: Optional[Union[bool, model.BoundedLinearTypes]] = None,
|
||||
bounded_expr: Optional[Union[bool, variables.BoundedLinearTypes]] = None,
|
||||
*,
|
||||
lb: Optional[float] = None,
|
||||
ub: Optional[float] = None,
|
||||
expr: Optional[model.LinearTypes] = None,
|
||||
expr: Optional[variables.LinearTypes] = None,
|
||||
) -> None:
|
||||
"""Shortcut for add_generated_constraint(..., is_lazy=True).."""
|
||||
self.add_generated_constraint(
|
||||
@@ -323,11 +335,11 @@ class CallbackResult:
|
||||
|
||||
def add_user_cut(
|
||||
self,
|
||||
bounded_expr: Optional[Union[bool, model.BoundedLinearTypes]] = None,
|
||||
bounded_expr: Optional[Union[bool, variables.BoundedLinearTypes]] = None,
|
||||
*,
|
||||
lb: Optional[float] = None,
|
||||
ub: Optional[float] = None,
|
||||
expr: Optional[model.LinearTypes] = None,
|
||||
expr: Optional[variables.LinearTypes] = None,
|
||||
) -> None:
|
||||
"""Shortcut for add_generated_constraint(..., is_lazy=False)."""
|
||||
self.add_generated_constraint(
|
||||
|
||||
@@ -134,6 +134,7 @@ class GeneratedLinearConstraintTest(
|
||||
|
||||
gen_con = callback.GeneratedConstraint()
|
||||
gen_con.terms = {x: 2.0, z: 4.0}
|
||||
gen_con.lower_bound = -math.inf
|
||||
gen_con.upper_bound = 5.0
|
||||
gen_con.is_lazy = True
|
||||
|
||||
@@ -245,7 +246,7 @@ class CallbackResultTest(compare_proto.MathOptProtoAssertions, absltest.TestCase
|
||||
"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.*"):
|
||||
with self.assertRaisesRegex(AssertionError, "lb cannot be specified.*"):
|
||||
result.add_user_cut(x + y == 1, lb=1)
|
||||
|
||||
def testToProtoEmpty(self) -> None:
|
||||
|
||||
@@ -19,8 +19,10 @@ from typing import FrozenSet, Mapping
|
||||
import immutabledict
|
||||
|
||||
from ortools.math_opt import infeasible_subsystem_pb2
|
||||
from ortools.math_opt.python import linear_constraints as linear_constraints_mod
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import result
|
||||
from ortools.math_opt.python import variables as variables_mod
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
@@ -72,13 +74,13 @@ class ModelSubset:
|
||||
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] = (
|
||||
variable_bounds: Mapping[variables_mod.Variable, ModelSubsetBounds] = (
|
||||
immutabledict.immutabledict()
|
||||
)
|
||||
variable_integrality: FrozenSet[variables_mod.Variable] = frozenset()
|
||||
linear_constraints: Mapping[
|
||||
linear_constraints_mod.LinearConstraint, ModelSubsetBounds
|
||||
] = immutabledict.immutabledict()
|
||||
|
||||
def empty(self) -> bool:
|
||||
"""Returns true if all the nested constraint collections are empty.
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for compute_infeasible_subsystem_result.py."""
|
||||
|
||||
from absl.testing import absltest
|
||||
from ortools.math_opt import infeasible_subsystem_pb2
|
||||
from ortools.math_opt.python import compute_infeasible_subsystem_result
|
||||
|
||||
31
ortools/math_opt/python/elemental/BUILD.bazel
Normal file
31
ortools/math_opt/python/elemental/BUILD.bazel
Normal file
@@ -0,0 +1,31 @@
|
||||
# Copyright 2010-2025 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")
|
||||
|
||||
package(default_visibility = [
|
||||
"//ortools/math_opt/elemental/python:__subpackages__",
|
||||
"//ortools/math_opt/python:__subpackages__",
|
||||
])
|
||||
|
||||
py_library(
|
||||
name = "elemental",
|
||||
srcs = ["elemental.py"],
|
||||
deps = [
|
||||
requirement("numpy"),
|
||||
"//ortools/math_opt:model_py_pb2",
|
||||
"//ortools/math_opt:model_update_py_pb2",
|
||||
"//ortools/math_opt/elemental/python:enums",
|
||||
],
|
||||
)
|
||||
398
ortools/math_opt/python/elemental/elemental.py
Normal file
398
ortools/math_opt/python/elemental/elemental.py
Normal file
@@ -0,0 +1,398 @@
|
||||
# Copyright 2010-2025 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 API for storing a MathOpt model and tracking model modifications."""
|
||||
|
||||
from typing import Optional, Protocol, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
# typing.Self is only in python 3.11+, for OR-tools supports down to 3.8.
|
||||
from typing_extensions import Self
|
||||
|
||||
from ortools.math_opt import model_pb2
|
||||
from ortools.math_opt import model_update_pb2
|
||||
from ortools.math_opt.elemental.python import enums
|
||||
|
||||
|
||||
class Elemental(Protocol):
|
||||
"""An API for building, modifying, and tracking changes to a MathOpt model.
|
||||
|
||||
On functions that return protocol buffers: These functions can fail for two
|
||||
reasons:
|
||||
(1) The data is too large for proto's in memory representation. Specifically,
|
||||
any repeated field can have at most 2^31 entries (~2 billion). So if your
|
||||
model has this many nonzeros in the constraint matrix, we cannot build a
|
||||
proto for it (we can potentially export to a text format still).
|
||||
(2) The particular combination of Elemental and Proto you are using must
|
||||
serialize your message (typically to cross a Python/C++ language
|
||||
boundary). Proto has a limit of 2GB for serialized messages, which is
|
||||
generally hit much earlier than the repeated field limit.
|
||||
|
||||
Note that for users solving locally, they can avoid needing to serialize
|
||||
their proto by:
|
||||
- using the C++ implementation of Elemental
|
||||
- using the upb or cpp implementations of proto for python and compile
|
||||
correctly, see go/fastpythonproto and
|
||||
https://github.com/protocolbuffers/protobuf/blob/main/python/README.md.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *, model_name: str = "", primary_objective_name: str = ""
|
||||
) -> None:
|
||||
"""Creates an empty optimization model.
|
||||
|
||||
Args:
|
||||
model_name: The name of the model, used for logging and export only.
|
||||
primary_objective_name: The name of the main objective of the problem.
|
||||
Typically used only for multi-objective problems.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def from_model_proto(cls, proto: model_pb2.ModelProto) -> Self:
|
||||
"""Returns an Elemental equivalent to the input proto."""
|
||||
|
||||
def clone(self, *, new_model_name: Optional[str] = None) -> Self:
|
||||
"""Returns a copy of this model with no associated diffs."""
|
||||
|
||||
@property
|
||||
def model_name(self) -> str:
|
||||
"""The name of the model."""
|
||||
|
||||
@property
|
||||
def primary_objective_name(self) -> str:
|
||||
"""The name of the primary objective of the model (rarely used)."""
|
||||
|
||||
def add_element(self, element_type: enums.ElementType, name: str) -> int:
|
||||
"""Adds an element of `element_type` to the model and returns its id."""
|
||||
|
||||
def add_elements(
|
||||
self, element_type: enums.ElementType, num: int
|
||||
) -> np.typing.NDArray[np.int64]:
|
||||
"""Adds `num` `element_type`s to the model and returns their ids.
|
||||
|
||||
All elements added will have the name ''.
|
||||
|
||||
Args:
|
||||
element_type: The ElementType of elements to add to the model.
|
||||
num: How many elements are added.
|
||||
|
||||
Returns:
|
||||
A numpy array with shape (num,) with the ids of the newly added elements.
|
||||
"""
|
||||
|
||||
def add_named_elements(
|
||||
self,
|
||||
element_type: enums.ElementType,
|
||||
names: np.typing.NDArray,
|
||||
) -> np.typing.NDArray[np.int64]:
|
||||
"""Adds an element of `element_type` for each name in names and returns ids.
|
||||
|
||||
Args:
|
||||
element_type: The ElementType of elements to add to the model.
|
||||
names: The names the elements, must have shape (n,) and string values.
|
||||
|
||||
Returns:
|
||||
A numpy array with shape (n,) with the ids of the newly added elements.
|
||||
"""
|
||||
|
||||
def delete_element(self, element_type: enums.ElementType, element_id: int) -> bool:
|
||||
"""Deletes element `id` of `element_type` from model, returns success."""
|
||||
|
||||
def delete_elements(
|
||||
self,
|
||||
element_type: enums.ElementType,
|
||||
elements: np.typing.NDArray[np.int64],
|
||||
) -> np.typing.NDArray[np.bool_]:
|
||||
"""Removes `elements` from the model, returning true elementwise on success.
|
||||
|
||||
A value of false is returned when an element was deleted in a previous call
|
||||
to delete elements or the element was never in the model. Note that
|
||||
repeating an id in `elements` for a single call to this function results in
|
||||
an exception.
|
||||
|
||||
Args:
|
||||
element_type: The ElementType of elements to delete from to the model.
|
||||
elements: The ids of the elements to delete, must have shape (n,).
|
||||
|
||||
Returns:
|
||||
A numpy array with shape (n,) indicating if each element was successfully
|
||||
deleted. (Entries are false when the element id was previously deleted or
|
||||
was never in the model.)
|
||||
|
||||
Raises:
|
||||
ValueError: if elements contains any duplicates. No modifications to the
|
||||
model will be applied when this exception is raised.
|
||||
"""
|
||||
|
||||
def get_element_name(self, element_type: enums.ElementType, element_id: int) -> str:
|
||||
"""Returns the name of the element `id` of ElementType `element_type`."""
|
||||
|
||||
def get_element_names(
|
||||
self,
|
||||
element_type: enums.ElementType,
|
||||
elements: np.typing.NDArray[np.int64],
|
||||
) -> np.typing.NDArray:
|
||||
"""Returns the name of each element in `elements`.
|
||||
|
||||
Note that elements have a default name of '' if no name is provided.
|
||||
|
||||
Args:
|
||||
element_type: The ElementType of elements to get the names for.
|
||||
elements: The ids of the elements, must have shape (n,).
|
||||
|
||||
Returns:
|
||||
A numpy array with shape (n,) containing the names.
|
||||
|
||||
Raises:
|
||||
ValueError: if any id from `elements` is not in the model.
|
||||
"""
|
||||
|
||||
def element_exists(self, element_type: enums.ElementType, element_id: int) -> bool:
|
||||
"""Returns if element `id` of ElementType `element_type` is in the model."""
|
||||
|
||||
def elements_exist(
|
||||
self,
|
||||
element_type: enums.ElementType,
|
||||
elements: np.typing.NDArray[np.int64],
|
||||
) -> np.typing.NDArray[np.bool_]:
|
||||
"""Returns if each id in `elements` is an element in the model.
|
||||
|
||||
Args:
|
||||
element_type: The ElementType to check.
|
||||
elements: The ids to look for, must have shape (n,).
|
||||
|
||||
Returns:
|
||||
A numpy array with shape (n,) containing true if each element is in the
|
||||
model (the id has been created and not deleted).
|
||||
"""
|
||||
|
||||
def get_next_element_id(self, element_type: enums.ElementType) -> int:
|
||||
"""Returns the next available element id of type `element_type`."""
|
||||
|
||||
def get_num_elements(self, element_type: enums.ElementType) -> int:
|
||||
"""Returns the number of elements of type `element_type` in the model."""
|
||||
|
||||
def get_elements(
|
||||
self, element_type: enums.ElementType
|
||||
) -> np.typing.NDArray[np.int64]:
|
||||
"""Returns all element ids for type `element_type` in unspecified order."""
|
||||
|
||||
def ensure_next_element_id_at_least(
|
||||
self, element_type: enums.ElementType, element_id: int
|
||||
) -> None:
|
||||
"""Increases next_element_id() to `element_id` if it is currently less."""
|
||||
|
||||
def set_attr(
|
||||
self,
|
||||
attr: enums.PyAttr[enums.AttrPyValueType],
|
||||
key: Sequence[int],
|
||||
values: enums.AttrPyValueType,
|
||||
) -> None:
|
||||
"""Sets an attribute to a value for a key."""
|
||||
|
||||
def set_attrs(
|
||||
self,
|
||||
attr: enums.Attr[enums.AttrValueType],
|
||||
keys: np.typing.NDArray[np.int64],
|
||||
values: np.typing.NDArray[enums.AttrValueType],
|
||||
) -> None:
|
||||
"""Sets the value of an attribute for a list of keys.
|
||||
|
||||
Args:
|
||||
attr: The attribute to modify, with k elements in each key.
|
||||
keys: An (n, k) array of n keys to set this attribute for.
|
||||
values: An array with shape (n,), the values to set for each key.
|
||||
|
||||
Raises:
|
||||
ValueError: if (1) any key is repeated (2) any key references an element
|
||||
not in the model, (3) the shape of keys are values is invalid, or (4)
|
||||
the shape of keys and values is inconsistent. No changes are applied for
|
||||
any key if the operation fails.
|
||||
"""
|
||||
|
||||
def get_attr(
|
||||
self, attr: enums.PyAttr[enums.AttrPyValueType], key: Sequence[int]
|
||||
) -> enums.AttrPyValueType:
|
||||
"""Returns the attribute value for a key.
|
||||
|
||||
The type of the attribute determines the number of elements in the key and
|
||||
return type. E.g. when attr=DoubleAttr1.VARIABLE_LOWER_BOUND, key should
|
||||
have size one (the element id of the variable) and the return type is float.
|
||||
|
||||
Args:
|
||||
attr: The attribute to query, which implies the key size and return type.
|
||||
key: A sequence of k ints, the element ids of the key.
|
||||
|
||||
Returns:
|
||||
The value for the key, or the default value for the attribute if the key
|
||||
is not set.
|
||||
|
||||
Raises:
|
||||
ValueError: if key is of the wrong size or key refers to an element id
|
||||
is not in the model.
|
||||
"""
|
||||
|
||||
def get_attrs(
|
||||
self,
|
||||
attr: enums.Attr[enums.AttrValueType],
|
||||
keys: np.typing.NDArray[np.int64],
|
||||
) -> np.typing.NDArray[enums.AttrValueType]:
|
||||
"""Returns the values of an attribute for a list of keys.
|
||||
|
||||
Repeated keys are okay.
|
||||
|
||||
Args:
|
||||
attr: The attribute to query, with k elements in each key.
|
||||
keys: An (n, k) array of n keys to read.
|
||||
|
||||
Returns:
|
||||
An array with shape (n,) with the values for each key. The default value
|
||||
of the attribute is returned if it was never set for the key.
|
||||
|
||||
Raises:
|
||||
ValueError: if (1) any key references an element not in the model or (2)
|
||||
the shape of keys is invalid.
|
||||
"""
|
||||
|
||||
def clear_attr(self, attr: enums.AnyAttr) -> None:
|
||||
"""Restores an attribute to its default value for every key."""
|
||||
|
||||
def is_attr_non_default(self, attr: enums.AnyAttr, key: Sequence[int]) -> bool:
|
||||
"""Returns true if the attribute has a non-default value for key."""
|
||||
|
||||
def bulk_is_attr_non_default(
|
||||
self, attr: enums.AnyAttr, keys: np.typing.NDArray[np.int64]
|
||||
) -> np.typing.NDArray[np.bool_]:
|
||||
"""Returns which keys take a value different from the attribute's default.
|
||||
|
||||
Repeated keys are okay.
|
||||
|
||||
Args:
|
||||
attr: The attribute to query, with k elements in each key.
|
||||
keys: An (n, k) array to of n keys to query.
|
||||
|
||||
Returns:
|
||||
An array with shape (n,), for each key, if it had a non-default value.
|
||||
|
||||
Raises:
|
||||
ValueError: if (1) any key references an element not in the model or (2)
|
||||
the shape of keys is invalid.
|
||||
"""
|
||||
|
||||
def get_attr_num_non_defaults(self, attr: enums.AnyAttr) -> int:
|
||||
"""Returns the number of keys with a non-default value for an attribute."""
|
||||
|
||||
def get_attr_non_defaults(self, attr: enums.AnyAttr) -> np.typing.NDArray[np.int64]:
|
||||
"""Returns the keys with a non-default value for an attribute.
|
||||
|
||||
Args:
|
||||
attr: The attribute to query, with k elements in each key.
|
||||
|
||||
Returns:
|
||||
An array with shape (n, k) if there are n keys with a non-default value
|
||||
for this attribute.
|
||||
"""
|
||||
|
||||
def slice_attr(
|
||||
self, attr: enums.AnyAttr, key_index: int, element_id: int
|
||||
) -> np.typing.NDArray[np.int64]:
|
||||
"""Returns the keys with a non-default value for an attribute along a slice.
|
||||
|
||||
Args:
|
||||
attr: The attribute to query, with k elements in each key.
|
||||
key_index: The index of the key to slice on, in [0..k).
|
||||
element_id: The value of the key to slice on, must be the id of an element
|
||||
with type given by the `key_index` key for `attr`.
|
||||
|
||||
Returns:
|
||||
An array with shape (n, k) if there are n keys along the slice with a
|
||||
non-default value for this attribute.
|
||||
|
||||
Raises:
|
||||
ValueError: if (1) `key_index` is not in [0..k) or (2) if no element with
|
||||
`element_id` exists.
|
||||
"""
|
||||
|
||||
def get_attr_slice_size(
|
||||
self, attr: enums.AnyAttr, key_index: int, element_id: int
|
||||
) -> int:
|
||||
"""Returns the number of keys in slice_attr(attr, key_index, element_id)."""
|
||||
|
||||
def export_model(self, *, remove_names: bool = False) -> model_pb2.ModelProto:
|
||||
"""Returns a ModelProto equivalent to this model.
|
||||
|
||||
Args:
|
||||
remove_names: If True, exclude names (e.g. variable names, the model name)
|
||||
from the returned proto.
|
||||
|
||||
Returns:
|
||||
The equivalent ModelProto.
|
||||
|
||||
Raises:
|
||||
ValueError: if the model is too big to fit into the proto, see class
|
||||
description for details.
|
||||
"""
|
||||
|
||||
def add_diff(self) -> int:
|
||||
"""Creates a new Diff to track changes to the model and returns its id."""
|
||||
|
||||
def delete_diff(self, diff_id: int) -> None:
|
||||
"""Stop tracking changes to the model for the Diff with id `diff_id`."""
|
||||
|
||||
def advance_diff(self, diff_id: int) -> None:
|
||||
"""Discards any previously tracked changes for this Diff.
|
||||
|
||||
The diff will now track changes from the point onward.
|
||||
|
||||
Args:
|
||||
diff_id: The id of to the Diff to advance.
|
||||
|
||||
Raises:
|
||||
ValueError: if diff_id does not reference a Diff for this model (e.g.,
|
||||
the Diff was already deleted).
|
||||
"""
|
||||
|
||||
def export_model_update(
|
||||
self, diff_id: int, *, remove_names: bool = False
|
||||
) -> Optional[model_update_pb2.ModelUpdateProto]:
|
||||
"""Returns a ModelUpdateProto with the changes for the Diff `diff_id`.
|
||||
|
||||
Args:
|
||||
diff_id: The id of the Diff to get changes for.
|
||||
remove_names: If True, exclude names (e.g. variable names, the model name)
|
||||
from the returned proto.
|
||||
|
||||
Returns:
|
||||
All changes to the model since the most recent call to
|
||||
`advance(diff_id)`, or since the Diff was created if it was never
|
||||
advanced. Returns `None` instead of an empty proto when there are no
|
||||
changes.
|
||||
|
||||
Raises:
|
||||
ValueError: if the update is too big to fit into the proto (see class
|
||||
description for details) or if diff_id does not reference a Diff for
|
||||
this model (e.g., the id was already deleted).
|
||||
"""
|
||||
|
||||
def apply_update(self, update_proto: model_update_pb2.ModelUpdateProto) -> None:
|
||||
"""Modifies the model to apply the changes from `update_proto`.
|
||||
|
||||
Args:
|
||||
update_proto: the changes to apply to the model.
|
||||
|
||||
Raises:
|
||||
ValueError: if the update proto is invalid for the current model, or if
|
||||
the implementation must serialize the proto and it is too large (see
|
||||
class description).
|
||||
"""
|
||||
@@ -16,26 +16,26 @@
|
||||
import typing
|
||||
from typing import Iterable, Mapping, Union
|
||||
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
|
||||
@typing.overload
|
||||
def fast_sum(summands: Iterable[model.LinearTypes]) -> model.LinearSum: ...
|
||||
def fast_sum(summands: Iterable[variables.LinearTypes]) -> variables.LinearSum: ...
|
||||
|
||||
|
||||
@typing.overload
|
||||
def fast_sum(
|
||||
summands: Iterable[model.QuadraticTypes],
|
||||
) -> Union[model.LinearSum, model.QuadraticSum]: ...
|
||||
summands: Iterable[variables.QuadraticTypes],
|
||||
) -> Union[variables.LinearSum, variables.QuadraticSum]: ...
|
||||
|
||||
|
||||
# TODO(b/312200030): There is a pytype bug so that for the code:
|
||||
# m = mathopt.Model()
|
||||
# x = m.Variable()
|
||||
# s = expressions.fast_sum([x*x, 4.0])
|
||||
# pytype picks the wrong overload and thinks s has type model.LinearSum, rather
|
||||
# than Union[model.LinearSum, model.QuadraticSum]. Once the bug is fixed,
|
||||
# confirm that the overloads actually work.
|
||||
# pytype picks the wrong overload and thinks s has type variables.LinearSum,
|
||||
# rather than Union[variables.LinearSum, variables.QuadraticSum]. Once the bug
|
||||
# is fixed, confirm that the overloads actually work.
|
||||
def fast_sum(summands):
|
||||
"""Sums the elements of summand into a linear or quadratic expression.
|
||||
|
||||
@@ -57,14 +57,14 @@ def fast_sum(summands):
|
||||
"""
|
||||
summands_tuple = tuple(summands)
|
||||
for s in summands_tuple:
|
||||
if isinstance(s, model.QuadraticBase):
|
||||
return model.QuadraticSum(summands_tuple)
|
||||
return model.LinearSum(summands_tuple)
|
||||
if isinstance(s, variables.QuadraticBase):
|
||||
return variables.QuadraticSum(summands_tuple)
|
||||
return variables.LinearSum(summands_tuple)
|
||||
|
||||
|
||||
def evaluate_expression(
|
||||
expression: model.QuadraticTypes,
|
||||
variable_values: Mapping[model.Variable, float],
|
||||
expression: variables.QuadraticTypes,
|
||||
variable_values: Mapping[variables.Variable, float],
|
||||
) -> float:
|
||||
"""Evaluates a linear or quadratic expression for given variable values.
|
||||
|
||||
@@ -78,6 +78,8 @@ def evaluate_expression(
|
||||
Returns:
|
||||
The value of the expression when replacing variables by their value.
|
||||
"""
|
||||
if isinstance(expression, model.QuadraticBase):
|
||||
return model.as_flat_quadratic_expression(expression).evaluate(variable_values)
|
||||
return model.as_flat_linear_expression(expression).evaluate(variable_values)
|
||||
if isinstance(expression, variables.QuadraticBase):
|
||||
return variables.as_flat_quadratic_expression(expression).evaluate(
|
||||
variable_values
|
||||
)
|
||||
return variables.as_flat_linear_expression(expression).evaluate(variable_values)
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
from absl.testing import absltest
|
||||
from ortools.math_opt.python import expressions
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
|
||||
def _type_check_linear_sum(x: model.LinearSum) -> None:
|
||||
def _type_check_linear_sum(x: variables.LinearSum) -> None:
|
||||
"""Does nothing at runtime, forces the type checker to run on x."""
|
||||
del x # Unused.
|
||||
|
||||
@@ -31,16 +32,16 @@ class FastSumTest(absltest.TestCase):
|
||||
z = 4
|
||||
result = expressions.fast_sum([x, y, z])
|
||||
_type_check_linear_sum(result)
|
||||
self.assertIsInstance(result, model.LinearSum)
|
||||
result_expr = model.as_flat_linear_expression(result)
|
||||
self.assertIsInstance(result, variables.LinearSum)
|
||||
result_expr = variables.as_flat_linear_expression(result)
|
||||
self.assertEqual(result_expr.offset, 4.0)
|
||||
self.assertDictEqual(dict(result_expr.terms), {x: 1.0, y: 1.0})
|
||||
|
||||
def test_numbers(self) -> None:
|
||||
result = expressions.fast_sum([2.0, 4.0])
|
||||
_type_check_linear_sum(result)
|
||||
self.assertIsInstance(result, model.LinearSum)
|
||||
result_expr = model.as_flat_linear_expression(result)
|
||||
self.assertIsInstance(result, variables.LinearSum)
|
||||
result_expr = variables.as_flat_linear_expression(result)
|
||||
self.assertEqual(result_expr.offset, 6.0)
|
||||
self.assertEmpty(result_expr.terms)
|
||||
|
||||
@@ -49,8 +50,8 @@ class FastSumTest(absltest.TestCase):
|
||||
x = mod.add_binary_variable()
|
||||
result = expressions.fast_sum([2.0, 3.0 * x])
|
||||
_type_check_linear_sum(result)
|
||||
self.assertIsInstance(result, model.LinearSum)
|
||||
result_expr = model.as_flat_linear_expression(result)
|
||||
self.assertIsInstance(result, variables.LinearSum)
|
||||
result_expr = variables.as_flat_linear_expression(result)
|
||||
self.assertEqual(result_expr.offset, 2.0)
|
||||
self.assertDictEqual(dict(result_expr.terms), {x: 3.0})
|
||||
|
||||
@@ -58,24 +59,26 @@ class FastSumTest(absltest.TestCase):
|
||||
mod = model.Model()
|
||||
x = mod.add_binary_variable()
|
||||
result = expressions.fast_sum([2.0, 3.0 * x * x, x])
|
||||
self.assertIsInstance(result, model.QuadraticSum)
|
||||
result_expr = model.as_flat_quadratic_expression(result)
|
||||
self.assertIsInstance(result, variables.QuadraticSum)
|
||||
result_expr = variables.as_flat_quadratic_expression(result)
|
||||
self.assertEqual(result_expr.offset, 2.0)
|
||||
self.assertDictEqual(dict(result_expr.linear_terms), {x: 1.0})
|
||||
self.assertDictEqual(
|
||||
dict(result_expr.quadratic_terms), {model.QuadraticTermKey(x, x): 3.0}
|
||||
dict(result_expr.quadratic_terms),
|
||||
{variables.QuadraticTermKey(x, x): 3.0},
|
||||
)
|
||||
|
||||
def test_all_quad(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_binary_variable()
|
||||
result = expressions.fast_sum([3.0 * x * x, x * x])
|
||||
self.assertIsInstance(result, model.QuadraticSum)
|
||||
result_expr = model.as_flat_quadratic_expression(result)
|
||||
self.assertIsInstance(result, variables.QuadraticSum)
|
||||
result_expr = variables.as_flat_quadratic_expression(result)
|
||||
self.assertEqual(result_expr.offset, 0.0)
|
||||
self.assertEmpty(result_expr.linear_terms)
|
||||
self.assertDictEqual(
|
||||
dict(result_expr.quadratic_terms), {model.QuadraticTermKey(x, x): 4.0}
|
||||
dict(result_expr.quadratic_terms),
|
||||
{variables.QuadraticTermKey(x, x): 4.0},
|
||||
)
|
||||
|
||||
|
||||
|
||||
37
ortools/math_opt/python/from_model.py
Normal file
37
ortools/math_opt/python/from_model.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Copyright 2010-2025 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.
|
||||
|
||||
"""Utilities for finding the model associated with a variable/constraint etc.
|
||||
|
||||
This file is an implementation detail and not part of the MathOpt public API.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
from ortools.math_opt.python.elemental import elemental
|
||||
|
||||
|
||||
class FromModel(Protocol):
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
@property
|
||||
def elemental(self) -> elemental.Elemental: ...
|
||||
|
||||
|
||||
def model_is_same(e1: FromModel, e2: FromModel) -> None:
|
||||
if e1.elemental is not e2.elemental:
|
||||
raise ValueError(
|
||||
f"Expected two elements from the same model, but observed {e1} from"
|
||||
f" model named: '{e1.elemental.model_name!r}', and {e2} from model"
|
||||
f" named: '{e2.elemental.model_name!r}'."
|
||||
)
|
||||
146
ortools/math_opt/python/indicator_constraints.py
Normal file
146
ortools/math_opt/python/indicator_constraints.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# Copyright 2010-2025 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.
|
||||
|
||||
"""Linear constraint in a model."""
|
||||
|
||||
from typing import Any, Iterator, Optional
|
||||
|
||||
from ortools.math_opt.elemental.python import enums
|
||||
from ortools.math_opt.python import from_model
|
||||
from ortools.math_opt.python import variables
|
||||
from ortools.math_opt.python.elemental import elemental
|
||||
|
||||
|
||||
class IndicatorConstraint(from_model.FromModel):
|
||||
"""An indicator constraint for an optimization model.
|
||||
|
||||
An IndicatorConstraint adds the following restriction on feasible solutions to
|
||||
an optimization model:
|
||||
if z == 1 then lb <= sum_{i in I} a_i * x_i <= ub
|
||||
where z is a binary decision variable (or its negation) and x_i are the
|
||||
decision variables of the problem. Equality constraints lb == ub is allowed,
|
||||
which models the constraint:
|
||||
if z == 1 then sum_{i in I} a_i * x_i == b
|
||||
Setting lb > ub will result in an InvalidArgument error at solve time.
|
||||
|
||||
Indicator constraints have limited mutability. You can delete a variable
|
||||
that the constraint uses, or you can delete the entire constraint. You
|
||||
currently cannot update bounds or coefficients. This may change in future
|
||||
versions.
|
||||
|
||||
If the indicator variable is deleted or was None at creation time, the
|
||||
constraint will lead to an invalid model at solve time, unless the constraint
|
||||
is deleted before solving.
|
||||
|
||||
The name is optional, read only, and used only for debugging. Non-empty names
|
||||
should be distinct.
|
||||
|
||||
Do not create an IndicatorConstraint directly, use
|
||||
Model.add_indicator_constraint() instead. Two IndicatorConstraint objects can
|
||||
represent the same constraint (for the same model). They will have the same
|
||||
underlying IndicatorConstraint.elemental for storing the data. The
|
||||
IndicatorConstraint class is simply a reference to an Elemental.
|
||||
"""
|
||||
|
||||
__slots__ = "_elemental", "_id"
|
||||
|
||||
def __init__(self, elem: elemental.Elemental, cid: int) -> None:
|
||||
"""Internal only, prefer Model functions (add_indicator_constraint() and get_indicator_constraint())."""
|
||||
if not isinstance(cid, int):
|
||||
raise TypeError(f"cid type should be int, was:{type(cid).__name__!r}")
|
||||
self._elemental: elemental.Elemental = elem
|
||||
self._id: int = cid
|
||||
|
||||
@property
|
||||
def lower_bound(self) -> float:
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr1.INDICATOR_CONSTRAINT_LOWER_BOUND, (self._id,)
|
||||
)
|
||||
|
||||
@property
|
||||
def upper_bound(self) -> float:
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr1.INDICATOR_CONSTRAINT_UPPER_BOUND, (self._id,)
|
||||
)
|
||||
|
||||
@property
|
||||
def activate_on_zero(self) -> bool:
|
||||
return self._elemental.get_attr(
|
||||
enums.BoolAttr1.INDICATOR_CONSTRAINT_ACTIVATE_ON_ZERO, (self._id,)
|
||||
)
|
||||
|
||||
@property
|
||||
def indicator_variable(self) -> Optional[variables.Variable]:
|
||||
var_id = self._elemental.get_attr(
|
||||
enums.VariableAttr1.INDICATOR_CONSTRAINT_INDICATOR, (self._id,)
|
||||
)
|
||||
if var_id < 0:
|
||||
return None
|
||||
return variables.Variable(self._elemental, var_id)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._elemental.get_element_name(
|
||||
enums.ElementType.INDICATOR_CONSTRAINT, self._id
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def elemental(self) -> elemental.Elemental:
|
||||
"""Internal use only."""
|
||||
return self._elemental
|
||||
|
||||
def get_coefficient(self, var: variables.Variable) -> float:
|
||||
from_model.model_is_same(var, self)
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr2.INDICATOR_CONSTRAINT_LINEAR_COEFFICIENT,
|
||||
(self._id, var.id),
|
||||
)
|
||||
|
||||
def terms(self) -> Iterator[variables.LinearTerm]:
|
||||
"""Yields the variable/coefficient pairs with nonzero coefficient for this linear constraint."""
|
||||
keys = self._elemental.slice_attr(
|
||||
enums.DoubleAttr2.INDICATOR_CONSTRAINT_LINEAR_COEFFICIENT, 0, self._id
|
||||
)
|
||||
coefs = self._elemental.get_attrs(
|
||||
enums.DoubleAttr2.INDICATOR_CONSTRAINT_LINEAR_COEFFICIENT, keys
|
||||
)
|
||||
for i in range(len(keys)):
|
||||
yield variables.LinearTerm(
|
||||
variable=variables.Variable(self._elemental, int(keys[i, 1])),
|
||||
coefficient=float(coefs[i]),
|
||||
)
|
||||
|
||||
def get_implied_constraint(self) -> variables.BoundedLinearExpression:
|
||||
"""Returns the bounded expression from lower_bound, upper_bound and terms."""
|
||||
return variables.BoundedLinearExpression(
|
||||
self.lower_bound, variables.LinearSum(self.terms()), self.upper_bound
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Returns the name, or a string containing the id if the name is empty."""
|
||||
return self.name if self.name else f"linear_constraint_{self.id}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LinearConstraint id: {self.id}, name: {self.name!r}>"
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if isinstance(other, IndicatorConstraint):
|
||||
return self._id == other._id and self._elemental is other._elemental
|
||||
return False
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self._id)
|
||||
125
ortools/math_opt/python/indicator_constraints_test.py
Normal file
125
ortools/math_opt/python/indicator_constraints_test.py
Normal file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2010-2025 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 Dict
|
||||
|
||||
from absl.testing import absltest
|
||||
from ortools.math_opt.python import indicator_constraints
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
|
||||
def _terms_dict(
|
||||
con: indicator_constraints.IndicatorConstraint,
|
||||
) -> Dict[variables.Variable, float]:
|
||||
return {term.variable: term.coefficient for term in con.terms()}
|
||||
|
||||
|
||||
class IndicatorConstraintsTest(absltest.TestCase):
|
||||
|
||||
def test_getters_empty(self) -> None:
|
||||
mod = model.Model()
|
||||
con = mod.add_indicator_constraint()
|
||||
self.assertIsNone(con.indicator_variable)
|
||||
self.assertEmpty(list(con.terms()))
|
||||
self.assertEqual(con.lower_bound, -math.inf)
|
||||
self.assertEqual(con.upper_bound, math.inf)
|
||||
self.assertFalse(con.activate_on_zero)
|
||||
self.assertEqual(con.name, "")
|
||||
bounded_expr = con.get_implied_constraint()
|
||||
self.assertEqual(bounded_expr.lower_bound, -math.inf)
|
||||
self.assertEqual(bounded_expr.upper_bound, math.inf)
|
||||
expr = variables.as_flat_linear_expression(bounded_expr.expression)
|
||||
self.assertEqual(expr.offset, 0.0)
|
||||
self.assertEmpty(expr.terms)
|
||||
|
||||
def test_getters_nonempty(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_binary_variable()
|
||||
y = mod.add_variable()
|
||||
z = mod.add_variable()
|
||||
con = mod.add_indicator_constraint(
|
||||
indicator=x,
|
||||
activate_on_zero=True,
|
||||
implied_constraint=2 * y + z == 3.0,
|
||||
name="c123",
|
||||
)
|
||||
self.assertEqual(con.indicator_variable, x)
|
||||
self.assertDictEqual(_terms_dict(con), {y: 2.0, z: 1.0})
|
||||
self.assertEqual(con.lower_bound, 3.0)
|
||||
self.assertEqual(con.upper_bound, 3.0)
|
||||
self.assertTrue(con.activate_on_zero)
|
||||
self.assertEqual(con.name, "c123")
|
||||
self.assertEqual(con.get_coefficient(y), 2.0)
|
||||
self.assertEqual(con.get_coefficient(x), 0.0)
|
||||
|
||||
bounded_expr = con.get_implied_constraint()
|
||||
self.assertEqual(bounded_expr.lower_bound, 3.0)
|
||||
self.assertEqual(bounded_expr.upper_bound, 3.0)
|
||||
expr = variables.as_flat_linear_expression(bounded_expr.expression)
|
||||
self.assertEqual(expr.offset, 0.0)
|
||||
self.assertEqual(expr.terms, {y: 2.0, z: 1.0})
|
||||
|
||||
def test_create_by_attrs(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_binary_variable()
|
||||
y = mod.add_variable()
|
||||
z = mod.add_variable()
|
||||
con = mod.add_indicator_constraint(
|
||||
indicator=x,
|
||||
activate_on_zero=True,
|
||||
implied_lb=4.0,
|
||||
implied_ub=5.0,
|
||||
implied_expr=10 * y + 9 * z + 3.0,
|
||||
name="c123",
|
||||
)
|
||||
self.assertEqual(con.indicator_variable, x)
|
||||
self.assertDictEqual(_terms_dict(con), {y: 10.0, z: 9.0})
|
||||
self.assertEqual(con.lower_bound, 1.0)
|
||||
self.assertEqual(con.upper_bound, 2.0)
|
||||
self.assertTrue(con.activate_on_zero)
|
||||
self.assertEqual(con.name, "c123")
|
||||
|
||||
def test_get_coefficient_wrong_model(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
mod2 = model.Model()
|
||||
con = mod2.add_indicator_constraint()
|
||||
with self.assertRaises(ValueError):
|
||||
con.get_coefficient(x)
|
||||
|
||||
def test_eq(self) -> None:
|
||||
mod_a = model.Model()
|
||||
con_a1 = mod_a.add_indicator_constraint()
|
||||
con_a2 = mod_a.add_indicator_constraint()
|
||||
con_a1_alt = mod_a.get_indicator_constraint(0)
|
||||
|
||||
mod_b = model.Model()
|
||||
con_b1 = mod_b.add_indicator_constraint()
|
||||
|
||||
self.assertEqual(con_a1, con_a1)
|
||||
self.assertEqual(con_a1, con_a1_alt)
|
||||
self.assertNotEqual(con_a1, con_a2)
|
||||
self.assertNotEqual(con_a1, con_b1)
|
||||
self.assertNotEqual(con_a1, "cat")
|
||||
|
||||
def test_hash_no_crash(self) -> None:
|
||||
mod_a = model.Model()
|
||||
con = mod_a.add_indicator_constraint()
|
||||
self.assertIsInstance(hash(con), int)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
@@ -16,10 +16,10 @@ from ortools.service.v1.mathopt import model_pb2 as api_model_pb2
|
||||
from ortools.service.v1.mathopt import parameters_pb2 as api_parameters_pb2
|
||||
from ortools.service.v1.mathopt import result_pb2 as api_result_pb2
|
||||
from ortools.service.v1.mathopt import solution_pb2 as api_solution_pb2
|
||||
from ortools.service.v1.mathopt import solver_resources_pb2 as api_solver_resources_pb2
|
||||
from ortools.service.v1.mathopt import (
|
||||
sparse_containers_pb2 as api_sparse_containers_pb2,
|
||||
)
|
||||
from google3.net.proto2.contrib.pyutil import compare
|
||||
from absl.testing import absltest
|
||||
from ortools.math_opt import parameters_pb2
|
||||
from ortools.math_opt import result_pb2
|
||||
@@ -28,6 +28,7 @@ from ortools.math_opt import solution_pb2
|
||||
from ortools.math_opt import sparse_containers_pb2
|
||||
from ortools.math_opt.python import mathopt
|
||||
from ortools.math_opt.python.ipc import proto_converter
|
||||
from ortools.math_opt.python.testing import compare_proto
|
||||
|
||||
|
||||
def _simple_request() -> rpc_pb2.SolveRequest:
|
||||
@@ -35,16 +36,19 @@ def _simple_request() -> rpc_pb2.SolveRequest:
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.maximize(x - y)
|
||||
params = mathopt.SolveParameters()
|
||||
resources = mathopt.SolverResources(cpu=2.0, ram=1024 * 1024 * 1024)
|
||||
params = mathopt.SolveParameters(threads=2)
|
||||
|
||||
request = rpc_pb2.SolveRequest(
|
||||
solver_type=parameters_pb2.SOLVER_TYPE_GSCIP,
|
||||
model=mod.export_model(),
|
||||
resources=resources.to_proto(),
|
||||
parameters=params.to_proto(),
|
||||
)
|
||||
return request
|
||||
|
||||
|
||||
class ProtoConverterTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
class ProtoConverterTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
|
||||
def test_convert_request(self):
|
||||
request = _simple_request()
|
||||
@@ -67,9 +71,13 @@ class ProtoConverterTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
),
|
||||
),
|
||||
),
|
||||
resources=api_solver_resources_pb2.SolverResourcesProto(
|
||||
cpu=2.0, ram=1024 * 1024 * 1024
|
||||
),
|
||||
parameters=api_parameters_pb2.SolveParametersProto(threads=2),
|
||||
)
|
||||
|
||||
self.assertProto2Equal(proto_converter.convert_request(request), expected)
|
||||
self.assert_protos_equal(proto_converter.convert_request(request), expected)
|
||||
|
||||
def test_initializer_is_not_supported(self):
|
||||
request = _simple_request()
|
||||
@@ -127,7 +135,7 @@ class ProtoConverterTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
)
|
||||
)
|
||||
|
||||
self.assertProto2Equal(
|
||||
self.assert_protos_equal(
|
||||
proto_converter.convert_response(api_response), expected_response
|
||||
)
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ def remote_http_solve(
|
||||
endpoint: Optional[str] = _DEFAULT_ENDPOINT,
|
||||
api_key: Optional[str] = None,
|
||||
deadline_sec: Optional[float] = _DEFAULT_DEADLINE_SEC,
|
||||
resources: Optional[mathopt.SolverResources] = None,
|
||||
) -> Tuple[mathopt.SolveResult, List[str]]:
|
||||
"""Solves a MathOpt model via HTTP request to the OR API.
|
||||
|
||||
@@ -50,6 +51,7 @@ def remote_http_solve(
|
||||
endpoint: An URI identifying the service for remote solves.
|
||||
api_key: Key to the OR API.
|
||||
deadline_sec: The number of seconds before the request times out.
|
||||
resources: Hints on resources requested for the solve.
|
||||
|
||||
Returns:
|
||||
A SolveResult containing the termination reason, solution(s) and stats.
|
||||
@@ -63,7 +65,7 @@ def remote_http_solve(
|
||||
# TODO(b/306709279): Relax this when unauthenticated solves are allowed.
|
||||
raise ValueError("api_key can't be None when solving remotely")
|
||||
|
||||
payload = _build_json_payload(model, solver_type, params, model_params)
|
||||
payload = _build_json_payload(model, solver_type, params, model_params, resources)
|
||||
|
||||
session = create_optimization_service_session(api_key, deadline_sec)
|
||||
response = session.post(
|
||||
@@ -115,6 +117,7 @@ def _build_json_payload(
|
||||
solver_type: mathopt.SolverType,
|
||||
params: Optional[mathopt.SolveParameters],
|
||||
model_params: Optional[mathopt.ModelSolveParameters],
|
||||
resources: Optional[mathopt.SolverResources],
|
||||
):
|
||||
"""Builds a JSON payload.
|
||||
|
||||
@@ -123,6 +126,7 @@ def _build_json_payload(
|
||||
solver_type: The underlying solver to use.
|
||||
params: Optional configuration of the underlying solver.
|
||||
model_params: Optional configuration of the solver that is model specific.
|
||||
resources: Hints on resources requested for the solve.
|
||||
|
||||
Returns:
|
||||
A JSON object with a MathOpt model and corresponding parameters.
|
||||
@@ -133,10 +137,12 @@ def _build_json_payload(
|
||||
"""
|
||||
params = params or mathopt.SolveParameters()
|
||||
model_params = model_params or mathopt.ModelSolveParameters()
|
||||
resources = resources or mathopt.SolverResources()
|
||||
try:
|
||||
request = rpc_pb2.SolveRequest(
|
||||
model=model.export_model(),
|
||||
solver_type=solver_type.value,
|
||||
resources=resources.to_proto(),
|
||||
parameters=params.to_proto(),
|
||||
model_parameters=model_params.to_proto(),
|
||||
)
|
||||
|
||||
@@ -103,7 +103,11 @@ class RemoteHttpSolveTest(absltest.TestCase):
|
||||
)
|
||||
|
||||
remote_solve_result, messages = remote_http_solve.remote_http_solve(
|
||||
mod, mathopt.SolverType.GSCIP, api_key=_MOCK_API_KEY
|
||||
mod,
|
||||
mathopt.SolverType.GSCIP,
|
||||
params=mathopt.SolveParameters(enable_output=True),
|
||||
api_key=_MOCK_API_KEY,
|
||||
resources=mathopt.SolverResources(ram=1024 * 1024 * 1024),
|
||||
)
|
||||
|
||||
self.assertGreaterEqual(len(remote_solve_result.solutions), 1)
|
||||
|
||||
155
ortools/math_opt/python/linear_constraints.py
Normal file
155
ortools/math_opt/python/linear_constraints.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# Copyright 2010-2025 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.
|
||||
|
||||
"""Linear constraint in a model."""
|
||||
|
||||
from typing import Any, Iterator, NamedTuple
|
||||
|
||||
from ortools.math_opt.elemental.python import enums
|
||||
from ortools.math_opt.python import from_model
|
||||
from ortools.math_opt.python import variables
|
||||
from ortools.math_opt.python.elemental import elemental
|
||||
|
||||
|
||||
class LinearConstraint(from_model.FromModel):
|
||||
"""A linear constraint for an optimization model.
|
||||
|
||||
A LinearConstraint adds the following restriction on feasible solutions to an
|
||||
optimization model:
|
||||
lb <= sum_{i in I} a_i * x_i <= ub
|
||||
where x_i are the decision variables of the problem. lb == ub is allowed, this
|
||||
models the equality constraint:
|
||||
sum_{i in I} a_i * x_i == b
|
||||
Setting lb > ub will result in an InvalidArgument error at solve time (the
|
||||
values are allowed to cross temporarily between solves).
|
||||
|
||||
A LinearConstraint can be configured as follows:
|
||||
* lower_bound: a float property, lb above. Should not be NaN nor +inf.
|
||||
* upper_bound: a float property, ub above. Should not be NaN nor -inf.
|
||||
* set_coefficient() and get_coefficient(): get and set the a_i * x_i
|
||||
terms. The variable must be from the same model as this constraint, and
|
||||
the a_i must be finite and not NaN. The coefficient for any variable not
|
||||
set is 0.0, and setting a coefficient to 0.0 removes it from I above.
|
||||
|
||||
The name is optional, read only, and used only for debugging. Non-empty names
|
||||
should be distinct.
|
||||
|
||||
Do not create a LinearConstraint directly, use Model.add_linear_constraint()
|
||||
instead. Two LinearConstraint objects can represent the same constraint (for
|
||||
the same model). They will have the same underlying LinearConstraint.elemental
|
||||
for storing the data. The LinearConstraint class is simply a reference to an
|
||||
Elemental.
|
||||
"""
|
||||
|
||||
__slots__ = "_elemental", "_id"
|
||||
|
||||
def __init__(self, elem: elemental.Elemental, cid: int) -> None:
|
||||
"""Internal only, prefer Model functions (add_linear_constraint() and get_linear_constraint())."""
|
||||
if not isinstance(cid, int):
|
||||
raise TypeError(f"cid type should be int, was:{type(cid).__name__!r}")
|
||||
self._elemental: elemental.Elemental = elem
|
||||
self._id: int = cid
|
||||
|
||||
@property
|
||||
def lower_bound(self) -> float:
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr1.LINEAR_CONSTRAINT_LOWER_BOUND, (self._id,)
|
||||
)
|
||||
|
||||
@lower_bound.setter
|
||||
def lower_bound(self, value: float) -> None:
|
||||
self._elemental.set_attr(
|
||||
enums.DoubleAttr1.LINEAR_CONSTRAINT_LOWER_BOUND, (self._id,), value
|
||||
)
|
||||
|
||||
@property
|
||||
def upper_bound(self) -> float:
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr1.LINEAR_CONSTRAINT_UPPER_BOUND, (self._id,)
|
||||
)
|
||||
|
||||
@upper_bound.setter
|
||||
def upper_bound(self, value: float) -> None:
|
||||
self._elemental.set_attr(
|
||||
enums.DoubleAttr1.LINEAR_CONSTRAINT_UPPER_BOUND, (self._id,), value
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._elemental.get_element_name(
|
||||
enums.ElementType.LINEAR_CONSTRAINT, self._id
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def elemental(self) -> elemental.Elemental:
|
||||
"""Internal use only."""
|
||||
return self._elemental
|
||||
|
||||
def set_coefficient(self, var: variables.Variable, coefficient: float) -> None:
|
||||
from_model.model_is_same(var, self)
|
||||
self._elemental.set_attr(
|
||||
enums.DoubleAttr2.LINEAR_CONSTRAINT_COEFFICIENT,
|
||||
(self._id, var.id),
|
||||
coefficient,
|
||||
)
|
||||
|
||||
def get_coefficient(self, var: variables.Variable) -> float:
|
||||
from_model.model_is_same(var, self)
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr2.LINEAR_CONSTRAINT_COEFFICIENT, (self._id, var.id)
|
||||
)
|
||||
|
||||
def terms(self) -> Iterator[variables.LinearTerm]:
|
||||
"""Yields the variable/coefficient pairs with nonzero coefficient for this linear constraint."""
|
||||
keys = self._elemental.slice_attr(
|
||||
enums.DoubleAttr2.LINEAR_CONSTRAINT_COEFFICIENT, 0, self._id
|
||||
)
|
||||
coefs = self._elemental.get_attrs(
|
||||
enums.DoubleAttr2.LINEAR_CONSTRAINT_COEFFICIENT, keys
|
||||
)
|
||||
for i in range(len(keys)):
|
||||
yield variables.LinearTerm(
|
||||
variable=variables.Variable(self._elemental, int(keys[i, 1])),
|
||||
coefficient=float(coefs[i]),
|
||||
)
|
||||
|
||||
def as_bounded_linear_expression(self) -> variables.BoundedLinearExpression:
|
||||
"""Returns the bounded expression from lower_bound, upper_bound and terms."""
|
||||
return variables.BoundedLinearExpression(
|
||||
self.lower_bound, variables.LinearSum(self.terms()), self.upper_bound
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Returns the name, or a string containing the id if the name is empty."""
|
||||
return self.name if self.name else f"linear_constraint_{self.id}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LinearConstraint id: {self.id}, name: {self.name!r}>"
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if isinstance(other, LinearConstraint):
|
||||
return self._id == other._id and self._elemental is other._elemental
|
||||
return False
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self._id)
|
||||
|
||||
|
||||
class LinearConstraintMatrixEntry(NamedTuple):
|
||||
linear_constraint: LinearConstraint
|
||||
variable: variables.Variable
|
||||
coefficient: float
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,7 @@ from ortools.math_opt.python.errors import InternalMathOptError
|
||||
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.indicator_constraints import IndicatorConstraint
|
||||
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 (
|
||||
@@ -85,58 +85,22 @@ 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.linear_constraints import LinearConstraint
|
||||
from ortools.math_opt.python.linear_constraints import LinearConstraintMatrixEntry
|
||||
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 ObjectiveParameters
|
||||
from ortools.math_opt.python.model_parameters import parse_objective_parameters
|
||||
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.objectives import AuxiliaryObjective
|
||||
from ortools.math_opt.python.objectives import Objective
|
||||
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
|
||||
@@ -149,6 +113,7 @@ 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.quadratic_constraints import QuadraticConstraint
|
||||
from ortools.math_opt.python.result import FeasibilityStatus
|
||||
from ortools.math_opt.python.result import Limit
|
||||
from ortools.math_opt.python.result import ObjectiveBounds
|
||||
@@ -185,12 +150,43 @@ from ortools.math_opt.python.solve import SolveCallback
|
||||
from ortools.math_opt.python.solver_resources import SolverResources
|
||||
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_quadratic_constraint_map
|
||||
from ortools.math_opt.python.sparse_containers import parse_variable_map
|
||||
from ortools.math_opt.python.sparse_containers import QuadraticConstraintFilter
|
||||
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
|
||||
from ortools.math_opt.python.variables import as_flat_linear_expression
|
||||
from ortools.math_opt.python.variables import as_flat_quadratic_expression
|
||||
from ortools.math_opt.python.variables import BoundedLinearExpression
|
||||
from ortools.math_opt.python.variables import BoundedLinearTypes
|
||||
from ortools.math_opt.python.variables import BoundedLinearTypesList
|
||||
from ortools.math_opt.python.variables import BoundedQuadraticExpression
|
||||
from ortools.math_opt.python.variables import BoundedQuadraticTypes
|
||||
from ortools.math_opt.python.variables import BoundedQuadraticTypesList
|
||||
from ortools.math_opt.python.variables import LinearBase
|
||||
from ortools.math_opt.python.variables import LinearExpression
|
||||
from ortools.math_opt.python.variables import LinearLinearProduct
|
||||
from ortools.math_opt.python.variables import LinearProduct
|
||||
from ortools.math_opt.python.variables import LinearSum
|
||||
from ortools.math_opt.python.variables import LinearTerm
|
||||
from ortools.math_opt.python.variables import LinearTypes
|
||||
from ortools.math_opt.python.variables import LinearTypesExceptVariable
|
||||
from ortools.math_opt.python.variables import LowerBoundedLinearExpression
|
||||
from ortools.math_opt.python.variables import LowerBoundedQuadraticExpression
|
||||
from ortools.math_opt.python.variables import QuadraticBase
|
||||
from ortools.math_opt.python.variables import QuadraticExpression
|
||||
from ortools.math_opt.python.variables import QuadraticProduct
|
||||
from ortools.math_opt.python.variables import QuadraticSum
|
||||
from ortools.math_opt.python.variables import QuadraticTerm
|
||||
from ortools.math_opt.python.variables import QuadraticTermKey
|
||||
from ortools.math_opt.python.variables import QuadraticTypes
|
||||
from ortools.math_opt.python.variables import UpperBoundedLinearExpression
|
||||
from ortools.math_opt.python.variables import UpperBoundedQuadraticExpression
|
||||
from ortools.math_opt.python.variables import VarEqVar
|
||||
from ortools.math_opt.python.variables import Variable
|
||||
|
||||
# pylint: enable=unused-import
|
||||
# pylint: enable=g-importing-member
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for mathopt."""
|
||||
import inspect
|
||||
import types
|
||||
import typing
|
||||
@@ -21,19 +20,22 @@ from typing import Any, List, Set, Tuple
|
||||
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 indicator_constraints
|
||||
from ortools.math_opt.python import init_arguments
|
||||
from ortools.math_opt.python import linear_constraints
|
||||
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 objectives
|
||||
from ortools.math_opt.python import parameters
|
||||
from ortools.math_opt.python import quadratic_constraints
|
||||
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 solver_resources
|
||||
from ortools.math_opt.python import sparse_containers
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
# This list does not contain some modules intentionally:
|
||||
#
|
||||
@@ -45,26 +47,32 @@ from ortools.math_opt.python import sparse_containers
|
||||
# would make sense to replace the top-level functions by member functions on
|
||||
# the Model.
|
||||
#
|
||||
# - `from_model`: this is an implementation detail, not part of the public API.
|
||||
#
|
||||
_MODULES_TO_CHECK: List[types.ModuleType] = [
|
||||
callback,
|
||||
expressions,
|
||||
hash_model_storage,
|
||||
indicator_constraints,
|
||||
init_arguments,
|
||||
linear_constraints,
|
||||
message_callback,
|
||||
model,
|
||||
model_parameters,
|
||||
model_storage,
|
||||
objectives,
|
||||
parameters,
|
||||
quadratic_constraints,
|
||||
result,
|
||||
sparse_containers,
|
||||
solution,
|
||||
solve,
|
||||
solver_resources,
|
||||
variables,
|
||||
]
|
||||
|
||||
# Some symbols are not meant to be exported; we exclude them here.
|
||||
_EXCLUDED_SYMBOLS: Set[Tuple[types.ModuleType, str]] = {
|
||||
(solution, "T"),
|
||||
(objectives, "PrimaryObjective"),
|
||||
}
|
||||
|
||||
_TYPING_PUBLIC_CONTENT = [
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for message_callback."""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
270
ortools/math_opt/python/model_element_test.py
Normal file
270
ortools/math_opt/python/model_element_test.py
Normal file
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2010-2025 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 adding and removing "Elements" (see Elemental) from the model."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import dataclasses
|
||||
from typing import Generic, Iterator, Protocol, TypeVar, Union
|
||||
|
||||
from absl.testing import absltest
|
||||
from absl.testing import parameterized
|
||||
from ortools.math_opt.python import indicator_constraints
|
||||
from ortools.math_opt.python import linear_constraints
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import objectives
|
||||
from ortools.math_opt.python import quadratic_constraints
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
# We cannot use Callable here because we need to support a named argument.
|
||||
class GetElement(Protocol, Generic[T]):
|
||||
|
||||
def __call__(
|
||||
self, mod: model.Model, element_id: int, *, validate: bool = True
|
||||
) -> T:
|
||||
pass
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ElementAdapter(Generic[T]):
|
||||
add: Callable[[model.Model], T]
|
||||
delete: Callable[[model.Model, T], None]
|
||||
has: Callable[[model.Model, int], bool]
|
||||
get: GetElement[T]
|
||||
get_all: Callable[[model.Model], Iterator[T]]
|
||||
num: Callable[[model.Model], int]
|
||||
next_id: Callable[[model.Model], int]
|
||||
ensure_next_id: Callable[[model.Model, int], None]
|
||||
|
||||
|
||||
_VARIABLE_ADAPTER = ElementAdapter[variables.Variable](
|
||||
add=model.Model.add_variable,
|
||||
delete=model.Model.delete_variable,
|
||||
has=model.Model.has_variable,
|
||||
get=model.Model.get_variable,
|
||||
get_all=model.Model.variables,
|
||||
num=model.Model.get_num_variables,
|
||||
next_id=model.Model.get_next_variable_id,
|
||||
ensure_next_id=model.Model.ensure_next_variable_id_at_least,
|
||||
)
|
||||
|
||||
_LINEAR_CONSTRAINT_ADAPTER = ElementAdapter[linear_constraints.LinearConstraint](
|
||||
add=model.Model.add_linear_constraint,
|
||||
delete=model.Model.delete_linear_constraint,
|
||||
has=model.Model.has_linear_constraint,
|
||||
get=model.Model.get_linear_constraint,
|
||||
get_all=model.Model.linear_constraints,
|
||||
num=model.Model.get_num_linear_constraints,
|
||||
next_id=model.Model.get_next_linear_constraint_id,
|
||||
ensure_next_id=model.Model.ensure_next_linear_constraint_id_at_least,
|
||||
)
|
||||
|
||||
|
||||
def _aux_add(mod: model.Model) -> objectives.AuxiliaryObjective:
|
||||
return mod.add_auxiliary_objective(priority=1)
|
||||
|
||||
|
||||
_AUX_OBJECTIVE_ADAPTER = ElementAdapter[objectives.AuxiliaryObjective](
|
||||
add=_aux_add,
|
||||
delete=model.Model.delete_auxiliary_objective,
|
||||
has=model.Model.has_auxiliary_objective,
|
||||
get=model.Model.get_auxiliary_objective,
|
||||
get_all=model.Model.auxiliary_objectives,
|
||||
num=model.Model.num_auxiliary_objectives,
|
||||
next_id=model.Model.next_auxiliary_objective_id,
|
||||
ensure_next_id=model.Model.ensure_next_auxiliary_objective_id_at_least,
|
||||
)
|
||||
|
||||
_QUADRATIC_CONSTRAINT_ADAPTER = ElementAdapter[
|
||||
quadratic_constraints.QuadraticConstraint
|
||||
](
|
||||
add=model.Model.add_quadratic_constraint,
|
||||
delete=model.Model.delete_quadratic_constraint,
|
||||
has=model.Model.has_quadratic_constraint,
|
||||
get=model.Model.get_quadratic_constraint,
|
||||
get_all=model.Model.get_quadratic_constraints,
|
||||
num=model.Model.get_num_quadratic_constraints,
|
||||
next_id=model.Model.get_next_quadratic_constraint_id,
|
||||
ensure_next_id=model.Model.ensure_next_quadratic_constraint_id_at_least,
|
||||
)
|
||||
|
||||
_INDICTOR_CONSTRAINT_ADAPTER = ElementAdapter[
|
||||
indicator_constraints.IndicatorConstraint
|
||||
](
|
||||
add=model.Model.add_indicator_constraint,
|
||||
delete=model.Model.delete_indicator_constraint,
|
||||
has=model.Model.has_indicator_constraint,
|
||||
get=model.Model.get_indicator_constraint,
|
||||
get_all=model.Model.get_indicator_constraints,
|
||||
num=model.Model.get_num_indicator_constraints,
|
||||
next_id=model.Model.get_next_indicator_constraint_id,
|
||||
ensure_next_id=model.Model.ensure_next_indicator_constraint_id_at_least,
|
||||
)
|
||||
|
||||
_ADAPTER = Union[
|
||||
ElementAdapter[variables.Variable],
|
||||
ElementAdapter[linear_constraints.LinearConstraint],
|
||||
ElementAdapter[objectives.AuxiliaryObjective],
|
||||
ElementAdapter[quadratic_constraints.QuadraticConstraint],
|
||||
ElementAdapter[indicator_constraints.IndicatorConstraint],
|
||||
]
|
||||
|
||||
|
||||
@parameterized.named_parameters(
|
||||
("variable", _VARIABLE_ADAPTER),
|
||||
("linear_constraint", _LINEAR_CONSTRAINT_ADAPTER),
|
||||
("auxiliary_objective", _AUX_OBJECTIVE_ADAPTER),
|
||||
("quadratic_constraint", _QUADRATIC_CONSTRAINT_ADAPTER),
|
||||
("indicator_constraint", _INDICTOR_CONSTRAINT_ADAPTER),
|
||||
)
|
||||
class ModelElementTest(parameterized.TestCase):
|
||||
|
||||
def test_no_elements(self, element_adapter: _ADAPTER) -> None:
|
||||
mod = model.Model()
|
||||
self.assertFalse(element_adapter.has(mod, 0))
|
||||
self.assertEqual(element_adapter.next_id(mod), 0)
|
||||
self.assertEqual(element_adapter.num(mod), 0)
|
||||
self.assertEmpty(list(element_adapter.get_all(mod)))
|
||||
|
||||
def test_add_element(self, element_adapter: _ADAPTER) -> None:
|
||||
mod = model.Model()
|
||||
e0 = element_adapter.add(mod)
|
||||
e1 = element_adapter.add(mod)
|
||||
e2 = element_adapter.add(mod)
|
||||
|
||||
self.assertTrue(element_adapter.has(mod, 0))
|
||||
self.assertTrue(element_adapter.has(mod, 1))
|
||||
self.assertTrue(element_adapter.has(mod, 2))
|
||||
self.assertFalse(element_adapter.has(mod, 3))
|
||||
|
||||
self.assertEqual(element_adapter.next_id(mod), 3)
|
||||
self.assertEqual(element_adapter.num(mod), 3)
|
||||
self.assertEqual(list(element_adapter.get_all(mod)), [e0, e1, e2])
|
||||
|
||||
self.assertEqual(element_adapter.get(mod, 1), e1)
|
||||
|
||||
def test_get_invalid_element(self, element_adapter: _ADAPTER) -> None:
|
||||
mod = model.Model()
|
||||
with self.assertRaises(KeyError):
|
||||
element_adapter.get(mod, 0, validate=True)
|
||||
# Check that default for validate is True as well
|
||||
with self.assertRaises(KeyError):
|
||||
element_adapter.get(mod, 0)
|
||||
|
||||
# No crash
|
||||
bad_el = element_adapter.get(mod, 0, validate=False)
|
||||
del bad_el
|
||||
|
||||
def test_delete_element(self, element_adapter: _ADAPTER) -> None:
|
||||
mod = model.Model()
|
||||
e0 = element_adapter.add(mod)
|
||||
e1 = element_adapter.add(mod)
|
||||
e2 = element_adapter.add(mod)
|
||||
|
||||
element_adapter.delete(mod, e1)
|
||||
|
||||
self.assertTrue(element_adapter.has(mod, 0))
|
||||
self.assertFalse(element_adapter.has(mod, 1))
|
||||
self.assertTrue(element_adapter.has(mod, 2))
|
||||
self.assertFalse(element_adapter.has(mod, 3))
|
||||
|
||||
self.assertEqual(element_adapter.next_id(mod), 3)
|
||||
self.assertEqual(element_adapter.num(mod), 2)
|
||||
self.assertEqual(list(element_adapter.get_all(mod)), [e0, e2])
|
||||
|
||||
self.assertEqual(element_adapter.get(mod, 2), e2)
|
||||
|
||||
def test_delete_invalid_element_error(self, element_adapter: _ADAPTER) -> None:
|
||||
mod = model.Model()
|
||||
bad_el = element_adapter.get(mod, 0, validate=False)
|
||||
with self.assertRaises(ValueError):
|
||||
element_adapter.delete(mod, bad_el)
|
||||
|
||||
def test_delete_element_twice_error(self, element_adapter: _ADAPTER) -> None:
|
||||
mod = model.Model()
|
||||
el = element_adapter.add(mod)
|
||||
element_adapter.delete(mod, el)
|
||||
with self.assertRaises(ValueError):
|
||||
element_adapter.delete(mod, el)
|
||||
|
||||
def test_delete_element_wrong_model_error(self, element_adapter: _ADAPTER) -> None:
|
||||
mod1 = model.Model()
|
||||
element_adapter.add(mod1)
|
||||
|
||||
mod2 = model.Model()
|
||||
e2 = element_adapter.add(mod2)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
element_adapter.delete(mod1, e2)
|
||||
|
||||
def test_get_deleted_element_error(self, element_adapter: _ADAPTER) -> None:
|
||||
mod = model.Model()
|
||||
el = element_adapter.add(mod)
|
||||
element_adapter.delete(mod, el)
|
||||
with self.assertRaises(KeyError):
|
||||
element_adapter.get(mod, 0, validate=True)
|
||||
|
||||
# No crash
|
||||
bad_el = element_adapter.get(mod, 0, validate=False)
|
||||
del bad_el
|
||||
|
||||
def test_ensure_next_id_with_effect(self, element_adapter: _ADAPTER) -> None:
|
||||
mod = model.Model()
|
||||
element_adapter.ensure_next_id(mod, 6)
|
||||
|
||||
self.assertEqual(element_adapter.next_id(mod), 6)
|
||||
self.assertFalse(element_adapter.has(mod, 0))
|
||||
self.assertFalse(element_adapter.has(mod, 6))
|
||||
self.assertEqual(element_adapter.num(mod), 0)
|
||||
self.assertEmpty(list(element_adapter.get_all(mod)))
|
||||
|
||||
e6 = element_adapter.add(mod)
|
||||
e7 = element_adapter.add(mod)
|
||||
|
||||
self.assertFalse(element_adapter.has(mod, 0))
|
||||
self.assertTrue(element_adapter.has(mod, 6))
|
||||
self.assertTrue(element_adapter.has(mod, 7))
|
||||
self.assertFalse(element_adapter.has(mod, 8))
|
||||
|
||||
self.assertEqual(element_adapter.next_id(mod), 8)
|
||||
self.assertEqual(element_adapter.num(mod), 2)
|
||||
self.assertEqual(list(element_adapter.get_all(mod)), [e6, e7])
|
||||
self.assertEqual(element_adapter.get(mod, 6), e6)
|
||||
self.assertEqual(element_adapter.get(mod, 7), e7)
|
||||
|
||||
def test_ensure_next_id_no_effect(self, element_adapter: _ADAPTER) -> None:
|
||||
mod = model.Model()
|
||||
e0 = element_adapter.add(mod)
|
||||
e1 = element_adapter.add(mod)
|
||||
e2 = element_adapter.add(mod)
|
||||
|
||||
element_adapter.ensure_next_id(mod, 1)
|
||||
|
||||
self.assertEqual(element_adapter.next_id(mod), 3)
|
||||
self.assertEqual(element_adapter.num(mod), 3)
|
||||
self.assertEqual(list(element_adapter.get_all(mod)), [e0, e1, e2])
|
||||
|
||||
e3 = element_adapter.add(mod)
|
||||
self.assertEqual(element_adapter.next_id(mod), 4)
|
||||
self.assertEqual(element_adapter.num(mod), 4)
|
||||
self.assertEqual(list(element_adapter.get_all(mod)), [e0, e1, e2, e3])
|
||||
self.assertEqual(element_adapter.get(mod, 3), e3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
336
ortools/math_opt/python/model_objective_test.py
Normal file
336
ortools/math_opt/python/model_objective_test.py
Normal file
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2010-2025 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 that the primary and auxiliary objectives are correct for model.py."""
|
||||
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from absl.testing import absltest
|
||||
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
|
||||
from ortools.math_opt.python import objectives
|
||||
from ortools.math_opt.python import variables
|
||||
from ortools.math_opt.python.testing import compare_proto
|
||||
|
||||
|
||||
def _lin_terms(obj: objectives.Objective) -> Dict[variables.Variable, float]:
|
||||
return {term.variable: term.coefficient for term in obj.linear_terms()}
|
||||
|
||||
|
||||
def _quad_terms(
|
||||
obj: objectives.Objective,
|
||||
) -> Dict[Tuple[variables.Variable, variables.Variable], float]:
|
||||
return {
|
||||
(term.key.first_var, term.key.second_var): term.coefficient
|
||||
for term in obj.quadratic_terms()
|
||||
}
|
||||
|
||||
|
||||
class ModelSetObjectiveTest(absltest.TestCase):
|
||||
|
||||
def test_maximize(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
mod.objective.set_linear_coefficient(x, 10.0)
|
||||
mod.objective.set_linear_coefficient(y, 11.0)
|
||||
|
||||
mod.maximize(3 * x * x + 2 * x + 1)
|
||||
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
self.assertEqual(mod.objective.offset, 1.0)
|
||||
self.assertDictEqual(_lin_terms(mod.objective), {x: 2.0})
|
||||
self.assertDictEqual(_quad_terms(mod.objective), {(x, x): 3.0})
|
||||
|
||||
def test_maximize_linear_obj(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
mod.objective.set_linear_coefficient(x, 10.0)
|
||||
mod.objective.set_linear_coefficient(y, 11.0)
|
||||
|
||||
mod.maximize_linear_objective(2 * x + 1)
|
||||
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
self.assertEqual(mod.objective.offset, 1.0)
|
||||
self.assertDictEqual(_lin_terms(mod.objective), {x: 2.0})
|
||||
self.assertEmpty(_quad_terms(mod.objective))
|
||||
|
||||
def test_maximize_linear_obj_type_error_quadratic(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
|
||||
with self.assertRaisesRegex(TypeError, "Quadratic"):
|
||||
mod.maximize_linear_objective(x * x) # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_maximize_quadratic_objective(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
mod.objective.set_linear_coefficient(x, 10.0)
|
||||
mod.objective.set_linear_coefficient(y, 11.0)
|
||||
|
||||
mod.maximize_quadratic_objective(3 * x * x + 2 * x + 1)
|
||||
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
self.assertEqual(mod.objective.offset, 1.0)
|
||||
self.assertDictEqual(_lin_terms(mod.objective), {x: 2.0})
|
||||
self.assertDictEqual(_quad_terms(mod.objective), {(x, x): 3.0})
|
||||
|
||||
def test_minimize(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
mod.objective.set_linear_coefficient(x, 10.0)
|
||||
mod.objective.set_linear_coefficient(y, 11.0)
|
||||
mod.objective.is_maximize = True
|
||||
|
||||
mod.minimize(3 * x * x + 2 * x + 1)
|
||||
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
self.assertEqual(mod.objective.offset, 1.0)
|
||||
self.assertDictEqual(_lin_terms(mod.objective), {x: 2.0})
|
||||
self.assertDictEqual(_quad_terms(mod.objective), {(x, x): 3.0})
|
||||
|
||||
def test_minimize_linear_obj(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
mod.objective.set_linear_coefficient(x, 10.0)
|
||||
mod.objective.set_linear_coefficient(y, 11.0)
|
||||
mod.objective.is_maximize = True
|
||||
|
||||
mod.minimize_linear_objective(2 * x + 1)
|
||||
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
self.assertEqual(mod.objective.offset, 1.0)
|
||||
self.assertDictEqual(_lin_terms(mod.objective), {x: 2.0})
|
||||
self.assertEmpty(_quad_terms(mod.objective))
|
||||
|
||||
def test_minimize_linear_obj_type_error_quadratic(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
|
||||
with self.assertRaisesRegex(TypeError, "Quadratic"):
|
||||
mod.minimize_linear_objective(x * x) # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_minimize_quadratic_objective(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
mod.objective.set_linear_coefficient(x, 10.0)
|
||||
mod.objective.set_linear_coefficient(y, 11.0)
|
||||
mod.objective.is_maximize = True
|
||||
|
||||
mod.minimize_quadratic_objective(3 * x * x + 2 * x + 1)
|
||||
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
self.assertEqual(mod.objective.offset, 1.0)
|
||||
self.assertDictEqual(_lin_terms(mod.objective), {x: 2.0})
|
||||
self.assertDictEqual(_quad_terms(mod.objective), {(x, x): 3.0})
|
||||
|
||||
def test_set_objective(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
mod.objective.set_linear_coefficient(x, 10.0)
|
||||
mod.objective.set_linear_coefficient(y, 11.0)
|
||||
|
||||
mod.set_objective(3 * x * x + 2 * x + 1, is_maximize=True)
|
||||
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
self.assertEqual(mod.objective.offset, 1.0)
|
||||
self.assertDictEqual(_lin_terms(mod.objective), {x: 2.0})
|
||||
self.assertDictEqual(_quad_terms(mod.objective), {(x, x): 3.0})
|
||||
|
||||
def test_set_objective_linear_obj(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
mod.objective.set_linear_coefficient(x, 10.0)
|
||||
mod.objective.set_linear_coefficient(y, 11.0)
|
||||
mod.objective.is_maximize = True
|
||||
|
||||
mod.set_linear_objective(2 * x + 1, is_maximize=False)
|
||||
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
self.assertEqual(mod.objective.offset, 1.0)
|
||||
self.assertDictEqual(_lin_terms(mod.objective), {x: 2.0})
|
||||
self.assertEmpty(_quad_terms(mod.objective))
|
||||
|
||||
def test_set_objective_linear_obj_type_error_quadratic(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
|
||||
with self.assertRaisesRegex(TypeError, "Quadratic"):
|
||||
mod.set_linear_objective(
|
||||
x * x, is_maximize=True
|
||||
) # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_set_objective_quadratic_objective(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
mod.objective.set_linear_coefficient(x, 10.0)
|
||||
mod.objective.set_linear_coefficient(y, 11.0)
|
||||
|
||||
mod.set_quadratic_objective(3 * x * x + 2 * x + 1, is_maximize=True)
|
||||
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
self.assertEqual(mod.objective.offset, 1.0)
|
||||
self.assertDictEqual(_lin_terms(mod.objective), {x: 2.0})
|
||||
self.assertDictEqual(_quad_terms(mod.objective), {(x, x): 3.0})
|
||||
|
||||
|
||||
class ModelAuxObjTest(absltest.TestCase):
|
||||
|
||||
def test_add_aux_obj_with_expr(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
aux = mod.add_auxiliary_objective(priority=10, expr=3.0 * x + 4.0, name="aux")
|
||||
|
||||
self.assertEqual(aux.offset, 4.0)
|
||||
self.assertDictEqual(_lin_terms(aux), {x: 3.0})
|
||||
self.assertFalse(aux.is_maximize)
|
||||
self.assertEqual(aux.priority, 10)
|
||||
self.assertEqual(aux.name, "aux")
|
||||
|
||||
def test_add_aux_obj_with_maximize(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
aux = mod.add_maximization_objective(3.0 * x + 4.0, priority=10, name="aux")
|
||||
|
||||
self.assertEqual(aux.offset, 4.0)
|
||||
self.assertDictEqual(_lin_terms(aux), {x: 3.0})
|
||||
self.assertTrue(aux.is_maximize)
|
||||
self.assertEqual(aux.priority, 10)
|
||||
self.assertEqual(aux.name, "aux")
|
||||
|
||||
def test_add_aux_obj_with_minimize(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
aux = mod.add_minimization_objective(3.0 * x + 4.0, priority=10, name="aux")
|
||||
|
||||
self.assertEqual(aux.offset, 4.0)
|
||||
self.assertDictEqual(_lin_terms(aux), {x: 3.0})
|
||||
self.assertFalse(aux.is_maximize)
|
||||
self.assertEqual(aux.priority, 10)
|
||||
self.assertEqual(aux.name, "aux")
|
||||
|
||||
|
||||
_PROTO_VARS = model_pb2.VariablesProto(
|
||||
ids=[0],
|
||||
lower_bounds=[-2.0],
|
||||
upper_bounds=[2.0],
|
||||
integers=[False],
|
||||
names=[""],
|
||||
)
|
||||
|
||||
|
||||
class ModelObjectiveExportProtoIntegrationTest(
|
||||
compare_proto.MathOptProtoAssertions, absltest.TestCase
|
||||
):
|
||||
"""Test Model.export_model() and UpdateTracker.export_update() for objectives.
|
||||
|
||||
These tests are not comprehensive, the proto generation code is completely
|
||||
tested in the tests for Elemental. We just want to make sure everything is
|
||||
connected.
|
||||
"""
|
||||
|
||||
def test_export_model_with_objective(self) -> None:
|
||||
mod = model.Model(primary_objective_name="obj-A")
|
||||
x = mod.add_variable(lb=-2.0, ub=2.0)
|
||||
mod.objective.priority = 3
|
||||
mod.maximize(3 * x * x + 4 * x + 5)
|
||||
proto_obj = model_pb2.ObjectiveProto(
|
||||
maximize=True,
|
||||
name="obj-A",
|
||||
priority=3,
|
||||
offset=5.0,
|
||||
linear_coefficients=sparse_containers_pb2.SparseDoubleVectorProto(
|
||||
ids=[0], values=[4.0]
|
||||
),
|
||||
quadratic_coefficients=sparse_containers_pb2.SparseDoubleMatrixProto(
|
||||
row_ids=[0], column_ids=[0], coefficients=[3.0]
|
||||
),
|
||||
)
|
||||
expected = model_pb2.ModelProto(variables=_PROTO_VARS, objective=proto_obj)
|
||||
|
||||
self.assert_protos_equal(mod.export_model(), expected)
|
||||
|
||||
def test_export_model_update_with_objective(self) -> None:
|
||||
mod = model.Model(primary_objective_name="obj-A")
|
||||
x = mod.add_variable(lb=-2.0, ub=2.0)
|
||||
tracker = mod.add_update_tracker()
|
||||
mod.objective.set_linear_coefficient(x, 2.5)
|
||||
|
||||
expected = model_update_pb2.ModelUpdateProto(
|
||||
objective_updates=model_update_pb2.ObjectiveUpdatesProto(
|
||||
linear_coefficients=sparse_containers_pb2.SparseDoubleVectorProto(
|
||||
ids=[0], values=[2.5]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
self.assert_protos_equal(tracker.export_update(), expected)
|
||||
|
||||
def test_export_model_with_auxiliary_objective(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable(lb=-2.0, ub=2.0)
|
||||
aux = mod.add_auxiliary_objective(priority=3, name="obj-A")
|
||||
aux.set_to_expression(4 * x + 5)
|
||||
aux.is_maximize = True
|
||||
|
||||
proto_obj = model_pb2.ObjectiveProto(
|
||||
maximize=True,
|
||||
name="obj-A",
|
||||
priority=3,
|
||||
offset=5.0,
|
||||
linear_coefficients=sparse_containers_pb2.SparseDoubleVectorProto(
|
||||
ids=[0], values=[4.0]
|
||||
),
|
||||
)
|
||||
|
||||
expected = model_pb2.ModelProto(
|
||||
variables=_PROTO_VARS, auxiliary_objectives={0: proto_obj}
|
||||
)
|
||||
|
||||
self.assert_protos_equal(mod.export_model(), expected)
|
||||
|
||||
def test_export_model_update_with_aux_obj_update(self) -> None:
|
||||
mod = model.Model(primary_objective_name="obj-A")
|
||||
x = mod.add_variable(lb=-2.0, ub=2.0)
|
||||
aux = mod.add_auxiliary_objective(priority=20)
|
||||
tracker = mod.add_update_tracker()
|
||||
aux.set_linear_coefficient(x, 2.5)
|
||||
|
||||
expected = model_update_pb2.ModelUpdateProto(
|
||||
auxiliary_objectives_updates=model_update_pb2.AuxiliaryObjectivesUpdatesProto(
|
||||
objective_updates={
|
||||
0: model_update_pb2.ObjectiveUpdatesProto(
|
||||
linear_coefficients=sparse_containers_pb2.SparseDoubleVectorProto(
|
||||
ids=[0], values=[2.5]
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assert_protos_equal(tracker.export_update(), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
@@ -13,12 +13,16 @@
|
||||
|
||||
"""Model specific solver configuration (e.g. starting basis)."""
|
||||
import dataclasses
|
||||
from typing import Dict, List, Optional
|
||||
import datetime
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from ortools.math_opt import model_parameters_pb2
|
||||
from ortools.math_opt.python import linear_constraints
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import objectives
|
||||
from ortools.math_opt.python import solution
|
||||
from ortools.math_opt.python import sparse_containers
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -51,10 +55,10 @@ class SolutionHint:
|
||||
constraints to finite (and not NaN) double values.
|
||||
"""
|
||||
|
||||
variable_values: Dict[model.Variable, float] = dataclasses.field(
|
||||
variable_values: Dict[variables.Variable, float] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
dual_values: Dict[model.LinearConstraint, float] = dataclasses.field(
|
||||
dual_values: Dict[linear_constraints.LinearConstraint, float] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
@@ -98,6 +102,81 @@ def parse_solution_hint(
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ObjectiveParameters:
|
||||
"""Parameters for an individual objective in a multi-objective model.
|
||||
|
||||
This class mirrors (and can generate) the related proto
|
||||
model_parameters_pb2.ObjectiveParametersProto.
|
||||
|
||||
Attributes:
|
||||
objective_degradation_absolute_tolerance: Optional objective degradation
|
||||
absolute tolerance. For a hierarchical multi-objective solver, each
|
||||
objective fⁱ is processed in priority order: the solver determines the
|
||||
optimal objective value Γⁱ, if it exists, subject to all constraints in
|
||||
the model and the additional constraints that fᵏ(x) = Γᵏ (within
|
||||
tolerances) for each k < i. If set, a solution is considered to be "within
|
||||
tolerances" for this objective fᵏ if |fᵏ(x) - Γᵏ| ≤
|
||||
`objective_degradation_absolute_tolerance`. See also
|
||||
`objective_degradation_relative_tolerance`; if both parameters are set for
|
||||
a given objective, the solver need only satisfy one to be considered
|
||||
"within tolerances". If not None, must be nonnegative.
|
||||
objective_degradation_relative_tolerance: Optional objective degradation
|
||||
relative tolerance. For a hierarchical multi-objective solver, each
|
||||
objective fⁱ is processed in priority order: the solver determines the
|
||||
optimal objective value Γⁱ, if it exists, subject to all constraints in
|
||||
the model and the additional constraints that fᵏ(x) = Γᵏ (within
|
||||
tolerances) for each k < i. If set, a solution is considered to be "within
|
||||
tolerances" for this objective fᵏ if |fᵏ(x) - Γᵏ| ≤
|
||||
`objective_degradation_relative_tolerance` * |Γᵏ|. See also
|
||||
`objective_degradation_absolute_tolerance`; if both parameters are set for
|
||||
a given objective, the solver need only satisfy one to be considered
|
||||
"within tolerances". If not None, must be nonnegative.
|
||||
time_limit: The maximum time a solver should spend on optimizing this
|
||||
particular objective (or infinite if not set). Note that this does not
|
||||
supersede the global time limit in SolveParameters.time_limit; both will
|
||||
be enforced when set. This value is not a hard limit, solve time may
|
||||
slightly exceed this value.
|
||||
"""
|
||||
|
||||
objective_degradation_absolute_tolerance: Optional[float] = None
|
||||
objective_degradation_relative_tolerance: Optional[float] = None
|
||||
time_limit: Optional[datetime.timedelta] = None
|
||||
|
||||
def to_proto(self) -> model_parameters_pb2.ObjectiveParametersProto:
|
||||
"""Returns an equivalent protocol buffer."""
|
||||
result = model_parameters_pb2.ObjectiveParametersProto()
|
||||
if self.objective_degradation_absolute_tolerance is not None:
|
||||
result.objective_degradation_absolute_tolerance = (
|
||||
self.objective_degradation_absolute_tolerance
|
||||
)
|
||||
if self.objective_degradation_relative_tolerance is not None:
|
||||
result.objective_degradation_relative_tolerance = (
|
||||
self.objective_degradation_relative_tolerance
|
||||
)
|
||||
if self.time_limit is not None:
|
||||
result.time_limit.FromTimedelta(self.time_limit)
|
||||
return result
|
||||
|
||||
|
||||
def parse_objective_parameters(
|
||||
proto: model_parameters_pb2.ObjectiveParametersProto,
|
||||
) -> ObjectiveParameters:
|
||||
"""Returns an equivalent ObjectiveParameters to the input proto."""
|
||||
result = ObjectiveParameters()
|
||||
if proto.HasField("objective_degradation_absolute_tolerance"):
|
||||
result.objective_degradation_absolute_tolerance = (
|
||||
proto.objective_degradation_absolute_tolerance
|
||||
)
|
||||
if proto.HasField("objective_degradation_relative_tolerance"):
|
||||
result.objective_degradation_relative_tolerance = (
|
||||
proto.objective_degradation_relative_tolerance
|
||||
)
|
||||
if proto.HasField("time_limit"):
|
||||
result.time_limit = proto.time_limit.ToTimedelta()
|
||||
return result
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ModelSolveParameters:
|
||||
"""Model specific solver configuration, for example, an initial basis.
|
||||
@@ -109,8 +188,10 @@ class ModelSolveParameters:
|
||||
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
|
||||
linear constraints accepted by this filter (default accepts all linear
|
||||
constraints).
|
||||
quadratic_dual_values_filter: Only return quadratic constraint dual values
|
||||
accepted by this filter (default accepts all quadratic 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.
|
||||
@@ -119,6 +200,14 @@ class ModelSolveParameters:
|
||||
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).
|
||||
objective_parameters: Optional per objective parameters used only only for
|
||||
multi-objective models.
|
||||
lazy_linear_constraints: Optional lazy constraint annotations. Included
|
||||
linear constraints will be marked as "lazy" with supporting solvers,
|
||||
meaning that they will only be added to the working model as-needed as the
|
||||
solver runs. Note that this an algorithmic hint that does not affect the
|
||||
model's feasible region; solvers not supporting these annotations will
|
||||
simply ignore it.
|
||||
"""
|
||||
|
||||
variable_values_filter: sparse_containers.VariableFilter = (
|
||||
@@ -127,14 +216,23 @@ class ModelSolveParameters:
|
||||
dual_values_filter: sparse_containers.LinearConstraintFilter = (
|
||||
sparse_containers.LinearConstraintFilter()
|
||||
)
|
||||
quadratic_dual_values_filter: sparse_containers.QuadraticConstraintFilter = (
|
||||
sparse_containers.QuadraticConstraintFilter()
|
||||
)
|
||||
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(
|
||||
branching_priorities: Dict[variables.Variable, int] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
objective_parameters: Dict[objectives.Objective, ObjectiveParameters] = (
|
||||
dataclasses.field(default_factory=dict)
|
||||
)
|
||||
lazy_linear_constraints: Set[linear_constraints.LinearConstraint] = (
|
||||
dataclasses.field(default_factory=set)
|
||||
)
|
||||
|
||||
def to_proto(self) -> model_parameters_pb2.ModelSolveParametersProto:
|
||||
"""Returns an equivalent protocol buffer."""
|
||||
@@ -143,6 +241,7 @@ class ModelSolveParameters:
|
||||
result = model_parameters_pb2.ModelSolveParametersProto(
|
||||
variable_values_filter=self.variable_values_filter.to_proto(),
|
||||
dual_values_filter=self.dual_values_filter.to_proto(),
|
||||
quadratic_dual_values_filter=self.quadratic_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
|
||||
@@ -152,4 +251,14 @@ class ModelSolveParameters:
|
||||
result.initial_basis.CopyFrom(self.initial_basis.to_proto())
|
||||
for hint in self.solution_hints:
|
||||
result.solution_hints.append(hint.to_proto())
|
||||
for obj, params in self.objective_parameters.items():
|
||||
if isinstance(obj, objectives.AuxiliaryObjective):
|
||||
result.auxiliary_objective_parameters[obj.id].CopyFrom(
|
||||
params.to_proto()
|
||||
)
|
||||
else:
|
||||
result.primary_objective_parameters.CopyFrom(params.to_proto())
|
||||
result.lazy_linear_constraint_ids[:] = sorted(
|
||||
con.id for con in self.lazy_linear_constraints
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import datetime
|
||||
|
||||
from google.protobuf import duration_pb2
|
||||
from absl.testing import absltest
|
||||
from ortools.math_opt import model_parameters_pb2
|
||||
from ortools.math_opt import solution_pb2
|
||||
@@ -39,11 +42,34 @@ class ModelParametersTest(compare_proto.MathOptProtoAssertions, absltest.TestCas
|
||||
self.assertDictEqual(hint_round_trip.variable_values, hint.variable_values)
|
||||
self.assertDictEqual(hint_round_trip.dual_values, hint.dual_values)
|
||||
|
||||
def test_objective_parameters_empty_round_trip(self) -> None:
|
||||
params = model_parameters.ObjectiveParameters()
|
||||
proto = model_parameters_pb2.ObjectiveParametersProto()
|
||||
self.assert_protos_equiv(params.to_proto(), proto)
|
||||
self.assertEqual(model_parameters.parse_objective_parameters(proto), params)
|
||||
|
||||
def test_objective_parameters_full_round_trip(self) -> None:
|
||||
params = model_parameters.ObjectiveParameters(
|
||||
objective_degradation_absolute_tolerance=4.1,
|
||||
objective_degradation_relative_tolerance=4.2,
|
||||
time_limit=datetime.timedelta(minutes=1),
|
||||
)
|
||||
proto = model_parameters_pb2.ObjectiveParametersProto(
|
||||
objective_degradation_absolute_tolerance=4.1,
|
||||
objective_degradation_relative_tolerance=4.2,
|
||||
time_limit=duration_pb2.Duration(seconds=60),
|
||||
)
|
||||
self.assert_protos_equiv(params.to_proto(), proto)
|
||||
self.assertEqual(model_parameters.parse_objective_parameters(proto), params)
|
||||
|
||||
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")
|
||||
# Ensure q and c have different ids.
|
||||
mod.add_quadratic_constraint()
|
||||
q = mod.add_quadratic_constraint(name="q")
|
||||
params = model_parameters.ModelSolveParameters()
|
||||
params.variable_values_filter = sparse_containers.SparseVectorFilter(
|
||||
filtered_items=(y,)
|
||||
@@ -54,6 +80,9 @@ class ModelParametersTest(compare_proto.MathOptProtoAssertions, absltest.TestCas
|
||||
params.dual_values_filter = sparse_containers.SparseVectorFilter(
|
||||
filtered_items=(c,)
|
||||
)
|
||||
params.quadratic_dual_values_filter = sparse_containers.SparseVectorFilter(
|
||||
filtered_items=(q,)
|
||||
)
|
||||
params.solution_hints.append(
|
||||
model_parameters.SolutionHint(
|
||||
variable_values={x: 1.0, y: 1.0}, dual_values={c: 3.0}
|
||||
@@ -74,6 +103,9 @@ class ModelParametersTest(compare_proto.MathOptProtoAssertions, absltest.TestCas
|
||||
dual_values_filter=sparse_containers_pb2.SparseVectorFilterProto(
|
||||
filter_by_ids=True, filtered_ids=(0,)
|
||||
),
|
||||
quadratic_dual_values_filter=sparse_containers_pb2.SparseVectorFilterProto(
|
||||
filter_by_ids=True, filtered_ids=(1,)
|
||||
),
|
||||
branching_priorities=sparse_containers_pb2.SparseInt32VectorProto(
|
||||
ids=[1], values=[2]
|
||||
),
|
||||
@@ -102,6 +134,53 @@ class ModelParametersTest(compare_proto.MathOptProtoAssertions, absltest.TestCas
|
||||
)
|
||||
self.assert_protos_equiv(expected, actual)
|
||||
|
||||
def test_model_parameters_to_proto_with_objective_params(self) -> None:
|
||||
mod = model.Model()
|
||||
aux1 = mod.add_auxiliary_objective(priority=1)
|
||||
mod.add_auxiliary_objective(priority=2)
|
||||
aux3 = mod.add_auxiliary_objective(priority=3)
|
||||
|
||||
def make_param(abs_tol: float) -> model_parameters.ObjectiveParameters:
|
||||
return model_parameters.ObjectiveParameters(
|
||||
objective_degradation_absolute_tolerance=abs_tol
|
||||
)
|
||||
|
||||
def make_proto_param(
|
||||
abs_tol: float,
|
||||
) -> model_parameters_pb2.ObjectiveParametersProto:
|
||||
return model_parameters_pb2.ObjectiveParametersProto(
|
||||
objective_degradation_absolute_tolerance=abs_tol
|
||||
)
|
||||
|
||||
model_params = model_parameters.ModelSolveParameters(
|
||||
objective_parameters={
|
||||
mod.objective: make_param(0.1),
|
||||
aux1: make_param(0.2),
|
||||
aux3: make_param(0.3),
|
||||
}
|
||||
)
|
||||
expected = model_parameters_pb2.ModelSolveParametersProto(
|
||||
primary_objective_parameters=make_proto_param(0.1),
|
||||
auxiliary_objective_parameters={
|
||||
0: make_proto_param(0.2),
|
||||
2: make_proto_param(0.3),
|
||||
},
|
||||
)
|
||||
self.assert_protos_equiv(model_params.to_proto(), expected)
|
||||
|
||||
def test_model_parameters_to_proto_with_lazy_constraints(self) -> None:
|
||||
mod = model.Model()
|
||||
c0 = mod.add_linear_constraint()
|
||||
mod.add_linear_constraint()
|
||||
c2 = mod.add_linear_constraint()
|
||||
model_params = model_parameters.ModelSolveParameters(
|
||||
lazy_linear_constraints={c0, c2}
|
||||
)
|
||||
expected = model_parameters_pb2.ModelSolveParametersProto(
|
||||
lazy_linear_constraint_ids=[0, 2]
|
||||
)
|
||||
self.assert_protos_equiv(model_params.to_proto(), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
|
||||
217
ortools/math_opt/python/model_quadratic_constraint_test.py
Normal file
217
ortools/math_opt/python/model_quadratic_constraint_test.py
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2010-2025 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 the functions on Model involving quadratic constraints."""
|
||||
|
||||
import math
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from absl.testing import absltest
|
||||
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
|
||||
from ortools.math_opt.python import quadratic_constraints
|
||||
from ortools.math_opt.python import variables
|
||||
from ortools.math_opt.python.testing import compare_proto
|
||||
|
||||
|
||||
def _lin_terms_dict(
|
||||
quad_con: quadratic_constraints.QuadraticConstraint,
|
||||
) -> Dict[variables.Variable, float]:
|
||||
return {term.variable: term.coefficient for term in quad_con.linear_terms()}
|
||||
|
||||
|
||||
def _quad_terms_dict(
|
||||
quad_con: quadratic_constraints.QuadraticConstraint,
|
||||
) -> Dict[Tuple[variables.Variable, variables.Variable], float]:
|
||||
return {
|
||||
(term.key.first_var, term.key.second_var): term.coefficient
|
||||
for term in quad_con.quadratic_terms()
|
||||
}
|
||||
|
||||
|
||||
class ModelQuadraticConstraintTest(absltest.TestCase):
|
||||
|
||||
def test_add_quadratic_constraint_expr_with_offset(self):
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
c = mod.add_quadratic_constraint(
|
||||
lb=-3.0, ub=3.0, expr=2.0 * x * x + 1.0, name="cde"
|
||||
)
|
||||
self.assertEqual(c.name, "cde")
|
||||
self.assertAlmostEqual(c.lower_bound, -4.0, delta=1e-10)
|
||||
self.assertAlmostEqual(c.upper_bound, 2.0, delta=1e-10)
|
||||
self.assertEmpty(list(c.linear_terms()))
|
||||
self.assertDictEqual(_quad_terms_dict(c), {(x, x): 2.0})
|
||||
|
||||
def test_add_quadratic_constraint_expr_with_offset_unbounded(self):
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
c = mod.add_quadratic_constraint(expr=2.0 * x * x + 1.0)
|
||||
self.assertEqual(c.lower_bound, -math.inf)
|
||||
self.assertEqual(c.upper_bound, math.inf)
|
||||
self.assertEmpty(list(c.linear_terms()))
|
||||
self.assertDictEqual(_quad_terms_dict(c), {(x, x): 2.0})
|
||||
|
||||
def test_add_quadratic_constraint_upper_bounded_expr(self):
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
c = mod.add_quadratic_constraint(
|
||||
2.0 * x * x + 3.0 * y + 4.0 <= 10.0, name="cde"
|
||||
)
|
||||
self.assertEqual(c.name, "cde")
|
||||
self.assertEqual(c.lower_bound, -math.inf)
|
||||
self.assertAlmostEqual(c.upper_bound, 6.0, delta=1e-10)
|
||||
self.assertDictEqual(_lin_terms_dict(c), {y: 3.0})
|
||||
self.assertDictEqual(_quad_terms_dict(c), {(x, x): 2.0})
|
||||
|
||||
def test_add_quadratic_constraint_lower_bounded_expr(self):
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
c = mod.add_quadratic_constraint(-10.0 <= 2.0 * x * x + 3.0 * y + 4.0)
|
||||
self.assertAlmostEqual(c.lower_bound, -14.0, delta=1e-10)
|
||||
self.assertEqual(c.upper_bound, math.inf)
|
||||
self.assertDictEqual(_lin_terms_dict(c), {y: 3.0})
|
||||
self.assertDictEqual(_quad_terms_dict(c), {(x, x): 2.0})
|
||||
|
||||
def test_add_quadratic_constraint_bounded_expr(self):
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
c = mod.add_quadratic_constraint((-10.0 <= 2.0 * x * x + 3.0 * y + 4.0) <= 10.0)
|
||||
self.assertAlmostEqual(c.lower_bound, -14.0, delta=1e-10)
|
||||
self.assertAlmostEqual(c.upper_bound, 6.0, delta=1e-10)
|
||||
self.assertDictEqual(_lin_terms_dict(c), {y: 3.0})
|
||||
self.assertDictEqual(_quad_terms_dict(c), {(x, x): 2.0})
|
||||
|
||||
def test_add_quadratic_no_variables_error(self):
|
||||
mod = model.Model()
|
||||
with self.assertRaisesRegex(TypeError, "constant left-hand-side"):
|
||||
mod.add_quadratic_constraint(-10.0 <= 12.0)
|
||||
|
||||
def test_add_quadratic_bad_double_inequality(self):
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
with self.assertRaisesRegex(TypeError, "two-sided or ranged"):
|
||||
mod.add_quadratic_constraint(1.0 <= x * x <= 2.0)
|
||||
|
||||
def test_all_linear_terms(self):
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
z = mod.add_variable()
|
||||
c = mod.add_quadratic_constraint(expr=2.0 * x + 3.0 * y)
|
||||
mod.add_quadratic_constraint(expr=x * x)
|
||||
e = mod.add_quadratic_constraint(expr=4.0 * x + 5.0 * z)
|
||||
self.assertCountEqual(
|
||||
list(mod.quadratic_constraint_linear_nonzeros()),
|
||||
[(c, x, 2.0), (c, y, 3.0), (e, x, 4.0), (e, z, 5.0)],
|
||||
)
|
||||
|
||||
def test_all_quadratic_terms(self):
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
z = mod.add_variable()
|
||||
c = mod.add_quadratic_constraint(expr=2.0 * x * x + 3.0 * y * x)
|
||||
mod.add_quadratic_constraint(expr=x)
|
||||
e = mod.add_quadratic_constraint(expr=4.0 * x * x + 5.0 * y * z)
|
||||
self.assertCountEqual(
|
||||
list(mod.quadratic_constraint_quadratic_nonzeros()),
|
||||
[(c, x, x, 2.0), (c, x, y, 3.0), (e, x, x, 4.0), (e, y, z, 5.0)],
|
||||
)
|
||||
|
||||
def test_quadratic_terms_empty(self):
|
||||
mod = model.Model()
|
||||
self.assertEmpty(list(mod.quadratic_constraint_linear_nonzeros()))
|
||||
self.assertEmpty(list(mod.quadratic_constraint_quadratic_nonzeros()))
|
||||
|
||||
|
||||
_PROTO_VARS = model_pb2.VariablesProto(
|
||||
ids=[0],
|
||||
lower_bounds=[-2.0],
|
||||
upper_bounds=[2.0],
|
||||
integers=[False],
|
||||
names=[""],
|
||||
)
|
||||
|
||||
# -3 <= 5 x^2 + 6 x <= 4
|
||||
_QUAD_CON = model_pb2.QuadraticConstraintProto(
|
||||
name="q1",
|
||||
lower_bound=-3.0,
|
||||
upper_bound=4.0,
|
||||
linear_terms=sparse_containers_pb2.SparseDoubleVectorProto(ids=[0], values=[6.0]),
|
||||
quadratic_terms=sparse_containers_pb2.SparseDoubleMatrixProto(
|
||||
row_ids=[0], column_ids=[0], coefficients=[5.0]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ModelQuadraticConstraintExportProtoIntegrationTest(
|
||||
compare_proto.MathOptProtoAssertions, absltest.TestCase
|
||||
):
|
||||
"""Test ModelProto and ModelUpdateProto export for quadratic constraints.
|
||||
|
||||
These tests are not comprehensive, the proto generation code is completely
|
||||
tested in the tests for Elemental. We just want to make sure everything is
|
||||
connected.
|
||||
"""
|
||||
|
||||
def test_export_model(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable(lb=-2.0, ub=2.0)
|
||||
mod.add_quadratic_constraint(
|
||||
lb=-3.0, ub=4.0, expr=5.0 * x * x + 6.0 * x, name="q1"
|
||||
)
|
||||
expected = model_pb2.ModelProto(
|
||||
variables=_PROTO_VARS, quadratic_constraints={0: _QUAD_CON}
|
||||
)
|
||||
self.assert_protos_equal(mod.export_model(), expected)
|
||||
|
||||
def test_export_model_update_add_constraint(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable(lb=-2.0, ub=2.0)
|
||||
tracker = mod.add_update_tracker()
|
||||
mod.add_quadratic_constraint(
|
||||
lb=-3.0, ub=4.0, expr=5.0 * x * x + 6.0 * x, name="q1"
|
||||
)
|
||||
|
||||
expected = model_update_pb2.ModelUpdateProto(
|
||||
quadratic_constraint_updates=model_update_pb2.QuadraticConstraintUpdatesProto(
|
||||
new_constraints={0: _QUAD_CON}
|
||||
)
|
||||
)
|
||||
|
||||
self.assert_protos_equal(tracker.export_update(), expected)
|
||||
|
||||
def test_export_model_update_delete_constraint(self) -> None:
|
||||
mod = model.Model()
|
||||
q = mod.add_quadratic_constraint()
|
||||
tracker = mod.add_update_tracker()
|
||||
mod.delete_quadratic_constraint(q)
|
||||
|
||||
expected = model_update_pb2.ModelUpdateProto(
|
||||
quadratic_constraint_updates=model_update_pb2.QuadraticConstraintUpdatesProto(
|
||||
deleted_constraint_ids=[0]
|
||||
)
|
||||
)
|
||||
|
||||
self.assert_protos_equal(tracker.export_update(), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
@@ -13,36 +13,29 @@
|
||||
# limitations under the License.
|
||||
|
||||
import math
|
||||
from typing import Type
|
||||
|
||||
from absl.testing import absltest
|
||||
from absl.testing import parameterized
|
||||
from ortools.math_opt import model_pb2
|
||||
from ortools.math_opt import model_update_pb2
|
||||
from ortools.math_opt import sparse_containers_pb2
|
||||
from ortools.math_opt.python import hash_model_storage
|
||||
from ortools.math_opt.python import linear_constraints
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import model_storage
|
||||
from ortools.math_opt.python import variables
|
||||
from ortools.math_opt.python.testing import compare_proto
|
||||
|
||||
StorageClass = Type[model_storage.ModelStorage]
|
||||
_MatEntry = model_storage.LinearConstraintMatrixIdEntry
|
||||
_ObjEntry = model_storage.LinearObjectiveEntry
|
||||
|
||||
class ModelTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
|
||||
@parameterized.parameters((hash_model_storage.HashModelStorage,))
|
||||
class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
|
||||
def test_name(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_name(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
self.assertEqual("test_model", mod.name)
|
||||
|
||||
def test_name_empty(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(storage_class=storage_class)
|
||||
def test_name_empty(self) -> None:
|
||||
mod = model.Model()
|
||||
self.assertEqual("", mod.name)
|
||||
|
||||
def test_add_and_read_variables(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_add_and_read_variables(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
v1 = mod.add_variable(lb=-1.0, ub=2.5, is_integer=True, name="x")
|
||||
v2 = mod.add_variable()
|
||||
self.assertEqual(-1.0, v1.lower_bound)
|
||||
@@ -65,15 +58,10 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual(v1, mod.get_variable(0))
|
||||
self.assertEqual(v2, mod.get_variable(1))
|
||||
|
||||
def test_get_variable_does_not_exist_key_error(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
with self.assertRaisesRegex(KeyError, "does not exist.*3"):
|
||||
mod.get_variable(3)
|
||||
|
||||
def test_add_integer_variable(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_add_integer_variable(self) -> None:
|
||||
mod = model.Model(
|
||||
name="test_model",
|
||||
)
|
||||
v1 = mod.add_integer_variable(lb=-1.0, ub=2.5, name="x")
|
||||
self.assertEqual(-1.0, v1.lower_bound)
|
||||
self.assertEqual(2.5, v1.upper_bound)
|
||||
@@ -81,8 +69,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual("x", v1.name)
|
||||
self.assertEqual(0, v1.id)
|
||||
|
||||
def test_add_binary_variable(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_add_binary_variable(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
v1 = mod.add_binary_variable(name="x")
|
||||
self.assertEqual(0.0, v1.lower_bound)
|
||||
self.assertEqual(1.0, v1.upper_bound)
|
||||
@@ -90,8 +78,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual("x", v1.name)
|
||||
self.assertEqual(0, v1.id)
|
||||
|
||||
def test_update_variable(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_update_variable(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
v1 = mod.add_variable(lb=-1.0, ub=2.5, is_integer=True, name="x")
|
||||
v1.lower_bound = -math.inf
|
||||
v1.upper_bound = -3.0
|
||||
@@ -100,46 +88,22 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual(-3.0, v1.upper_bound)
|
||||
self.assertFalse(v1.integer)
|
||||
|
||||
def test_delete_variable(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
z = mod.add_binary_variable(name="z")
|
||||
self.assertListEqual([x, y, z], list(mod.variables()))
|
||||
mod.delete_variable(y)
|
||||
self.assertListEqual([x, z], list(mod.variables()))
|
||||
|
||||
def test_delete_variable_twice(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_read_deleted_variable(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
x = mod.add_binary_variable(name="x")
|
||||
mod.delete_variable(x)
|
||||
with self.assertRaises(LookupError):
|
||||
mod.delete_variable(x)
|
||||
|
||||
def test_read_deleted_variable(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
mod.delete_variable(x)
|
||||
with self.assertRaises(LookupError):
|
||||
with self.assertRaises(ValueError):
|
||||
x.lower_bound # pylint: disable=pointless-statement
|
||||
|
||||
def test_update_deleted_variable(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_update_deleted_variable(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
x = mod.add_binary_variable(name="x")
|
||||
mod.delete_variable(x)
|
||||
with self.assertRaises(LookupError):
|
||||
with self.assertRaises(ValueError):
|
||||
x.upper_bound = 2.0
|
||||
|
||||
def test_delete_variable_wrong_model(self, storage_class: StorageClass) -> None:
|
||||
mod1 = model.Model(name="mod1", storage_class=storage_class)
|
||||
mod1.add_binary_variable(name="x")
|
||||
mod2 = model.Model(name="mod2", storage_class=storage_class)
|
||||
x2 = mod2.add_binary_variable(name="x")
|
||||
with self.assertRaises(ValueError):
|
||||
mod1.delete_variable(x2)
|
||||
|
||||
def test_add_and_read_linear_constraints(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_add_and_read_linear_constraints(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c")
|
||||
d = mod.add_linear_constraint()
|
||||
self.assertEqual(-1.0, c.lower_bound)
|
||||
@@ -160,519 +124,42 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual(c, mod.get_linear_constraint(0))
|
||||
self.assertEqual(d, mod.get_linear_constraint(1))
|
||||
|
||||
def test_linear_constraint_as_bounded_expression(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_as_bounded_expression(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=-1.0, ub=2.5, name="c", expr=3 * x - 2 * y)
|
||||
bounded_expr = c.as_bounded_linear_expression()
|
||||
self.assertEqual(bounded_expr.lower_bound, -1.0)
|
||||
self.assertEqual(bounded_expr.upper_bound, 2.5)
|
||||
expr = model.as_flat_linear_expression(bounded_expr.expression)
|
||||
expr = variables.as_flat_linear_expression(bounded_expr.expression)
|
||||
self.assertEqual(expr.offset, 0.0)
|
||||
self.assertDictEqual(dict(expr.terms), {x: 3.0, y: -2.0})
|
||||
|
||||
def test_get_linear_constraint_does_not_exist_key_error(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
with self.assertRaisesRegex(KeyError, "does not exist.*3"):
|
||||
mod.get_linear_constraint(3)
|
||||
|
||||
def test_update_linear_constraint(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_update_linear_constraint(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c")
|
||||
c.lower_bound = -math.inf
|
||||
c.upper_bound = -3.0
|
||||
self.assertEqual(-math.inf, c.lower_bound)
|
||||
self.assertEqual(-3.0, c.upper_bound)
|
||||
|
||||
def test_delete_linear_constraint(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c")
|
||||
d = mod.add_linear_constraint(lb=0.0, ub=1.0, name="d")
|
||||
e = mod.add_linear_constraint(lb=1.0, name="e")
|
||||
self.assertListEqual([c, d, e], list(mod.linear_constraints()))
|
||||
mod.delete_linear_constraint(d)
|
||||
self.assertListEqual([c, e], list(mod.linear_constraints()))
|
||||
|
||||
def test_delete_linear_constraint_twice(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_read_deleted_linear_constraint(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c")
|
||||
mod.delete_linear_constraint(c)
|
||||
with self.assertRaises(LookupError):
|
||||
mod.delete_linear_constraint(c)
|
||||
|
||||
def test_read_deleted_linear_constraint(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c")
|
||||
mod.delete_linear_constraint(c)
|
||||
with self.assertRaises(LookupError):
|
||||
with self.assertRaises(ValueError):
|
||||
c.name # pylint: disable=pointless-statement
|
||||
|
||||
def test_update_deleted_linear_constraint(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_update_deleted_linear_constraint(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
c = mod.add_linear_constraint(lb=-1.0, ub=2.5, name="c")
|
||||
mod.delete_linear_constraint(c)
|
||||
with self.assertRaises(LookupError):
|
||||
with self.assertRaises(ValueError):
|
||||
c.lower_bound = -12.0
|
||||
|
||||
def test_delete_linear_constraint_wrong_model(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod1 = model.Model(name="test_model", storage_class=storage_class)
|
||||
mod1.add_linear_constraint(lb=-1.0, ub=2.5, name="c")
|
||||
mod2 = model.Model(name="mod2", storage_class=storage_class)
|
||||
c2 = mod2.add_linear_constraint(lb=-1.0, ub=2.5, name="c")
|
||||
with self.assertRaises(ValueError):
|
||||
mod1.delete_linear_constraint(c2)
|
||||
|
||||
def test_set_objective_linear(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
z = mod.add_binary_variable(name="z")
|
||||
w = mod.add_binary_variable(name="w")
|
||||
|
||||
mod.set_objective(2 * (x - 2 * y) + 1 + 3 * z, is_maximize=True)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(3.0, mod.objective.get_linear_coefficient(z))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(w))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
|
||||
mod.set_objective(w + 2, is_maximize=False)
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(z))
|
||||
self.assertEqual(1.0, mod.objective.get_linear_coefficient(w))
|
||||
self.assertEqual(2.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
def test_set_linear_objective(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
z = mod.add_binary_variable(name="z")
|
||||
w = mod.add_binary_variable(name="w")
|
||||
|
||||
mod.set_linear_objective(2 * (x - 2 * y) + 1 + 3 * z, is_maximize=True)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(3.0, mod.objective.get_linear_coefficient(z))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(w))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
|
||||
mod.set_linear_objective(w + 2, is_maximize=False)
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(z))
|
||||
self.assertEqual(1.0, mod.objective.get_linear_coefficient(w))
|
||||
self.assertEqual(2.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
def test_set_objective_quadratic(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
|
||||
mod.set_objective(2 * x * (x - 2 * y) + 1 + 3 * x, is_maximize=True)
|
||||
self.assertEqual(3.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(2.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(-4.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
|
||||
mod.set_objective(x * x + 2, is_maximize=False)
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(2.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
def test_set_quadratic_objective(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
|
||||
mod.set_quadratic_objective(2 * x * (x - 2 * y) + 1 + 3 * x, is_maximize=True)
|
||||
self.assertEqual(3.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(2.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(-4.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
|
||||
mod.set_quadratic_objective(x * x + 2, is_maximize=False)
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(2.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
def test_maximize_expr_linear(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.maximize(2 * x - y + 1)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
|
||||
mod.objective.clear()
|
||||
mod.maximize_linear_objective(2 * x - y + 1)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
|
||||
def test_maximize_expr_quadratic(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.maximize(2 * x - y + 1 + x * x)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
|
||||
mod.objective.clear()
|
||||
mod.maximize_quadratic_objective(2 * x - y + 1 + x * x)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
|
||||
def test_minimize_expr_linear(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.minimize(2 * x - y + 1)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
mod.objective.clear()
|
||||
mod.minimize_linear_objective(2 * x - y + 1)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
def test_minimize_expr_quadratic(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.minimize(2 * x - y + 1 + x * x)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
mod.objective.clear()
|
||||
mod.minimize_quadratic_objective(2 * x - y + 1 + x * x)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-1.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(1.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
def test_add_to_objective_linear(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.minimize(2 * x - y + 1)
|
||||
mod.objective.add(x - 3 * y - 2)
|
||||
self.assertEqual(3.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(-1.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
mod.minimize(2 * x - y + 1)
|
||||
mod.objective.add_linear(x - 3 * y - 2)
|
||||
self.assertEqual(3.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(-1.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
def test_add_to_objective_quadratic(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.minimize(2 * x - y + 1 + x * x)
|
||||
mod.objective.add(x - 3 * y - 2 - 2 * x * x + x * y)
|
||||
self.assertEqual(3.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(-1.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(-1.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
mod.minimize(2 * x - y + 1 + x * x)
|
||||
mod.objective.add_quadratic(x - 3 * y - 2 - 2 * x * x + x * y)
|
||||
self.assertEqual(3.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(-4.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(-1.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(-1.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
def test_add_to_objective_type_errors(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
with self.assertRaises(TypeError):
|
||||
mod.objective.add_linear(x * x) # pytype: disable=wrong-arg-types
|
||||
with self.assertRaises(TypeError):
|
||||
mod.objective.add("obj") # pytype: disable=wrong-arg-types
|
||||
with self.assertRaises(TypeError):
|
||||
mod.objective.add_quadratic("obj") # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_clear_objective(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.minimize(2 * x - y + 1 + x * x)
|
||||
mod.objective.clear()
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(x, y))
|
||||
self.assertEqual(0.0, mod.objective.get_quadratic_coefficient(y, y))
|
||||
self.assertEqual(0.0, mod.objective.offset)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
def test_objective_offset(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
self.assertEqual(0.0, mod.objective.offset)
|
||||
mod.objective.offset = 4.4
|
||||
self.assertEqual(4.4, mod.objective.offset)
|
||||
|
||||
def test_objective_direction(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
mod.objective.is_maximize = True
|
||||
self.assertTrue(mod.objective.is_maximize)
|
||||
mod.objective.is_maximize = False
|
||||
self.assertFalse(mod.objective.is_maximize)
|
||||
|
||||
def test_objective_linear_terms(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
w = mod.add_binary_variable(name="w")
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
z = mod.add_binary_variable(name="z")
|
||||
for v in (w, x, y, z):
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(v))
|
||||
self.assertCountEqual([], mod.objective.linear_terms())
|
||||
mod.objective.set_linear_coefficient(x, 0.0)
|
||||
mod.objective.set_linear_coefficient(y, 1.0)
|
||||
mod.objective.set_linear_coefficient(z, 10.0)
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(w))
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(x))
|
||||
self.assertEqual(1.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertEqual(10.0, mod.objective.get_linear_coefficient(z))
|
||||
self.assertCountEqual(
|
||||
[
|
||||
repr(model.LinearTerm(variable=y, coefficient=1.0)),
|
||||
repr(model.LinearTerm(variable=z, coefficient=10.0)),
|
||||
],
|
||||
[repr(term) for term in mod.objective.linear_terms()],
|
||||
)
|
||||
|
||||
mod.objective.set_linear_coefficient(z, 0.0)
|
||||
self.assertEqual(0.0, mod.objective.get_linear_coefficient(z))
|
||||
self.assertCountEqual(
|
||||
[repr(model.LinearTerm(variable=y, coefficient=1.0))],
|
||||
[repr(term) for term in mod.objective.linear_terms()],
|
||||
)
|
||||
|
||||
def test_objective_quadratic_terms(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
self.assertCountEqual([], mod.objective.quadratic_terms())
|
||||
mod.objective.set_linear_coefficient(x, 0.0)
|
||||
mod.objective.set_quadratic_coefficient(x, x, 1.0)
|
||||
mod.objective.set_quadratic_coefficient(x, y, 2.0)
|
||||
self.assertCountEqual(
|
||||
[
|
||||
repr(
|
||||
model.QuadraticTerm(
|
||||
key=model.QuadraticTermKey(x, x), coefficient=1.0
|
||||
)
|
||||
),
|
||||
repr(
|
||||
model.QuadraticTerm(
|
||||
key=model.QuadraticTermKey(x, y), coefficient=2.0
|
||||
)
|
||||
),
|
||||
],
|
||||
[repr(term) for term in mod.objective.quadratic_terms()],
|
||||
)
|
||||
|
||||
mod.objective.set_quadratic_coefficient(x, x, 0.0)
|
||||
self.assertCountEqual(
|
||||
[
|
||||
repr(
|
||||
model.QuadraticTerm(
|
||||
key=model.QuadraticTermKey(x, y), coefficient=2.0
|
||||
)
|
||||
)
|
||||
],
|
||||
[repr(term) for term in mod.objective.quadratic_terms()],
|
||||
)
|
||||
|
||||
mod.objective.set_quadratic_coefficient(x, y, 0.0)
|
||||
self.assertEmpty(list(mod.objective.quadratic_terms()))
|
||||
|
||||
def test_objective_as_expression_linear(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.maximize(x + 2 * y - 1)
|
||||
linear_expr = mod.objective.as_linear_expression()
|
||||
quadratic_expr = mod.objective.as_quadratic_expression()
|
||||
self.assertEqual(-1, linear_expr.offset)
|
||||
self.assertEqual(-1, quadratic_expr.offset)
|
||||
self.assertDictEqual(dict(linear_expr.terms), {x: 1.0, y: 2.0})
|
||||
self.assertDictEqual(dict(quadratic_expr.linear_terms), {x: 1.0, y: 2.0})
|
||||
self.assertDictEqual(dict(quadratic_expr.quadratic_terms), {})
|
||||
|
||||
def test_objective_as_expression_quadratic(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.maximize(3 * x * y + 4 * x * x + x + 2 * y - 1)
|
||||
quadratic_expr = mod.objective.as_quadratic_expression()
|
||||
self.assertEqual(-1, quadratic_expr.offset)
|
||||
self.assertDictEqual(dict(quadratic_expr.linear_terms), {x: 1.0, y: 2.0})
|
||||
self.assertDictEqual(
|
||||
dict(quadratic_expr.quadratic_terms),
|
||||
{model.QuadraticTermKey(x, x): 4, model.QuadraticTermKey(x, y): 3},
|
||||
)
|
||||
with self.assertRaises(TypeError):
|
||||
mod.objective.as_linear_expression()
|
||||
|
||||
def test_objective_with_variable_deletion_linear(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.objective.set_linear_coefficient(x, 1.0)
|
||||
mod.objective.set_linear_coefficient(y, 2.0)
|
||||
mod.delete_variable(x)
|
||||
self.assertEqual(2.0, mod.objective.get_linear_coefficient(y))
|
||||
self.assertCountEqual(
|
||||
[repr(model.LinearTerm(variable=y, coefficient=2.0))],
|
||||
[repr(term) for term in mod.objective.linear_terms()],
|
||||
)
|
||||
with self.assertRaises(LookupError):
|
||||
mod.objective.get_linear_coefficient(x)
|
||||
|
||||
def test_objective_with_variable_deletion_quadratic(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
y = mod.add_binary_variable(name="y")
|
||||
mod.objective.set_quadratic_coefficient(x, x, 1.0)
|
||||
mod.objective.set_quadratic_coefficient(x, y, 2.0)
|
||||
mod.delete_variable(y)
|
||||
self.assertEqual(1.0, mod.objective.get_quadratic_coefficient(x, x))
|
||||
self.assertCountEqual(
|
||||
[
|
||||
repr(
|
||||
model.QuadraticTerm(
|
||||
key=model.QuadraticTermKey(x, x), coefficient=1.0
|
||||
)
|
||||
)
|
||||
],
|
||||
[repr(term) for term in mod.objective.quadratic_terms()],
|
||||
)
|
||||
with self.assertRaises(LookupError):
|
||||
mod.objective.get_quadratic_coefficient(x, y)
|
||||
with self.assertRaises(LookupError):
|
||||
mod.objective.get_quadratic_coefficient(y, x)
|
||||
|
||||
def test_objective_wrong_model_linear(self, storage_class: StorageClass) -> None:
|
||||
mod1 = model.Model(name="test_model1", storage_class=storage_class)
|
||||
x = mod1.add_binary_variable(name="x")
|
||||
mod2 = model.Model(name="test_model2", storage_class=storage_class)
|
||||
mod2.add_binary_variable(name="x")
|
||||
with self.assertRaises(ValueError):
|
||||
mod2.objective.set_linear_coefficient(x, 1.0)
|
||||
|
||||
def test_objective_wrong_model_quadratic(self, storage_class: StorageClass) -> None:
|
||||
mod1 = model.Model(name="test_model1", storage_class=storage_class)
|
||||
x = mod1.add_binary_variable(name="x")
|
||||
mod2 = model.Model(name="test_model2", storage_class=storage_class)
|
||||
other_x = mod2.add_binary_variable(name="x")
|
||||
with self.assertRaises(ValueError):
|
||||
mod2.objective.set_quadratic_coefficient(x, other_x, 1.0)
|
||||
with self.assertRaises(ValueError):
|
||||
mod2.objective.set_quadratic_coefficient(other_x, x, 1.0)
|
||||
|
||||
def test_objective_type_errors(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
x = mod.add_binary_variable(name="x")
|
||||
with self.assertRaises(TypeError):
|
||||
mod.set_linear_objective(
|
||||
x * x, is_maximize=True
|
||||
) # pytype: disable=wrong-arg-types
|
||||
with self.assertRaises(TypeError):
|
||||
mod.maximize_linear_objective(x * x) # pytype: disable=wrong-arg-types
|
||||
with self.assertRaises(TypeError):
|
||||
mod.minimize_linear_objective(x * x) # pytype: disable=wrong-arg-types
|
||||
with self.assertRaises(TypeError):
|
||||
mod.set_quadratic_objective(
|
||||
"obj", is_maximize=True
|
||||
) # pytype: disable=wrong-arg-types
|
||||
with self.assertRaises(TypeError):
|
||||
mod.maximize_quadratic_objective("obj") # pytype: disable=wrong-arg-types
|
||||
with self.assertRaises(TypeError):
|
||||
mod.minimize_quadratic_objective("obj") # pytype: disable=wrong-arg-types
|
||||
with self.assertRaises(TypeError):
|
||||
mod.set_objective(
|
||||
"obj", is_maximize=True
|
||||
) # pytype: disable=wrong-arg-types
|
||||
with self.assertRaises(TypeError):
|
||||
mod.minimize("obj") # pytype: disable=wrong-arg-types
|
||||
with self.assertRaises(TypeError):
|
||||
mod.maximize("obj") # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_linear_constraint_matrix(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_matrix(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")
|
||||
@@ -696,35 +183,38 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertCountEqual([], mod.column_nonzeros(y))
|
||||
self.assertCountEqual([d], mod.column_nonzeros(z))
|
||||
|
||||
self.assertCountEqual([x], mod.row_nonzeros(c))
|
||||
self.assertCountEqual([x, z], mod.row_nonzeros(d))
|
||||
|
||||
self.assertCountEqual(
|
||||
[repr(model.LinearTerm(variable=x, coefficient=1.0))],
|
||||
[repr(variables.LinearTerm(variable=x, coefficient=1.0))],
|
||||
[repr(term) for term in c.terms()],
|
||||
)
|
||||
self.assertCountEqual(
|
||||
[
|
||||
repr(model.LinearTerm(variable=x, coefficient=2.0)),
|
||||
repr(model.LinearTerm(variable=z, coefficient=-1.0)),
|
||||
repr(variables.LinearTerm(variable=x, coefficient=2.0)),
|
||||
repr(variables.LinearTerm(variable=z, coefficient=-1.0)),
|
||||
],
|
||||
[repr(term) for term in d.terms()],
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[
|
||||
model.LinearConstraintMatrixEntry(
|
||||
linear_constraints.LinearConstraintMatrixEntry(
|
||||
linear_constraint=c, variable=x, coefficient=1.0
|
||||
),
|
||||
model.LinearConstraintMatrixEntry(
|
||||
linear_constraints.LinearConstraintMatrixEntry(
|
||||
linear_constraint=d, variable=x, coefficient=2.0
|
||||
),
|
||||
model.LinearConstraintMatrixEntry(
|
||||
linear_constraints.LinearConstraintMatrixEntry(
|
||||
linear_constraint=d, variable=z, coefficient=-1.0
|
||||
),
|
||||
],
|
||||
mod.linear_constraint_matrix_entries(),
|
||||
list(mod.linear_constraint_matrix_entries()),
|
||||
)
|
||||
|
||||
def test_linear_constraint_expression(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_expression(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")
|
||||
@@ -756,10 +246,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual(-math.inf, f.lower_bound)
|
||||
self.assertEqual(1, f.upper_bound)
|
||||
|
||||
def test_linear_constraint_bounded_expression(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_bounded_expression(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")
|
||||
@@ -771,10 +259,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual(-1.0, c.lower_bound)
|
||||
self.assertEqual(0.0, c.upper_bound)
|
||||
|
||||
def test_linear_constraint_upper_bounded_expression(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_upper_bounded_expression(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")
|
||||
@@ -786,10 +272,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual(-math.inf, d.lower_bound)
|
||||
self.assertEqual(-1.0, d.upper_bound)
|
||||
|
||||
def test_linear_constraint_lower_bounded_expression(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_lower_bounded_expression(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")
|
||||
@@ -801,10 +285,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual(-1.0, e.lower_bound)
|
||||
self.assertEqual(math.inf, e.upper_bound)
|
||||
|
||||
def test_linear_constraint_number_eq_expression(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_number_eq_expression(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")
|
||||
@@ -816,10 +298,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual(-1.0, f.lower_bound)
|
||||
self.assertEqual(-1.0, f.upper_bound)
|
||||
|
||||
def test_linear_constraint_expression_eq_expression(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_expression_eq_expression(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")
|
||||
@@ -831,10 +311,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual(1.0, f.lower_bound)
|
||||
self.assertEqual(1.0, f.upper_bound)
|
||||
|
||||
def test_linear_constraint_variable_eq_variable(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_variable_eq_variable(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")
|
||||
@@ -846,15 +324,15 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assertEqual(0.0, f.lower_bound)
|
||||
self.assertEqual(0.0, f.upper_bound)
|
||||
|
||||
def test_linear_constraint_errors(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_errors(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")
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
TypeError,
|
||||
"unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left",
|
||||
"Unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left",
|
||||
):
|
||||
mod.add_linear_constraint(x != y)
|
||||
|
||||
@@ -866,19 +344,19 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
TypeError,
|
||||
"unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left",
|
||||
"Unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left",
|
||||
):
|
||||
mod.add_linear_constraint(1 <= 2) # pylint: disable=comparison-of-constants
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
TypeError,
|
||||
"unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left",
|
||||
"Unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left",
|
||||
):
|
||||
mod.add_linear_constraint(1 <= 0) # pylint: disable=comparison-of-constants
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
TypeError,
|
||||
"unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left",
|
||||
"Unsupported type for bounded_expr.*bool.*!= constraints.*constant" " left",
|
||||
):
|
||||
mod.add_linear_constraint(True)
|
||||
|
||||
@@ -903,27 +381,25 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
with self.assertRaisesRegex(TypeError, "unsupported operand.*"):
|
||||
mod.add_linear_constraint((0 <= x) >= z)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "lb cannot be specified.*"):
|
||||
with self.assertRaisesRegex(AssertionError, "lb cannot be specified.*"):
|
||||
mod.add_linear_constraint(x + y == 1, lb=1)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "ub cannot be specified.*"):
|
||||
with self.assertRaisesRegex(AssertionError, "ub cannot be specified.*"):
|
||||
mod.add_linear_constraint(x + y == 1, ub=1)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "expr cannot be specified.*"):
|
||||
with self.assertRaisesRegex(AssertionError, "expr cannot be specified.*"):
|
||||
mod.add_linear_constraint(x + y == 1, expr=2 * x)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
TypeError, "unsupported type for expr argument.*str"
|
||||
TypeError, "Unsupported type for expr argument.*str"
|
||||
):
|
||||
mod.add_linear_constraint(expr="string") # pytype: disable=wrong-arg-types
|
||||
|
||||
with self.assertRaisesRegex(ValueError, ".*infinite offset."):
|
||||
mod.add_linear_constraint(expr=math.inf, lb=0.0)
|
||||
|
||||
def test_linear_constraint_matrix_with_variable_deletion(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_linear_constraint_matrix_with_variable_deletion(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")
|
||||
@@ -934,7 +410,7 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
mod.delete_variable(x)
|
||||
self.assertCountEqual(
|
||||
[
|
||||
model.LinearConstraintMatrixEntry(
|
||||
linear_constraints.LinearConstraintMatrixEntry(
|
||||
linear_constraint=c, variable=y, coefficient=2.0
|
||||
)
|
||||
],
|
||||
@@ -942,17 +418,17 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
)
|
||||
self.assertCountEqual([c], mod.column_nonzeros(y))
|
||||
self.assertCountEqual(
|
||||
[repr(model.LinearTerm(variable=y, coefficient=2.0))],
|
||||
[repr(variables.LinearTerm(variable=y, coefficient=2.0))],
|
||||
[repr(term) for term in c.terms()],
|
||||
)
|
||||
self.assertCountEqual([], d.terms())
|
||||
with self.assertRaises(LookupError):
|
||||
with self.assertRaises(ValueError):
|
||||
c.get_coefficient(x)
|
||||
|
||||
def test_linear_constraint_matrix_with_linear_constraint_deletion(
|
||||
self, storage_class: StorageClass
|
||||
self,
|
||||
) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
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")
|
||||
@@ -963,32 +439,30 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
mod.delete_linear_constraint(c)
|
||||
self.assertCountEqual(
|
||||
[
|
||||
model.LinearConstraintMatrixEntry(
|
||||
linear_constraints.LinearConstraintMatrixEntry(
|
||||
linear_constraint=d, variable=x, coefficient=1.0
|
||||
)
|
||||
],
|
||||
mod.linear_constraint_matrix_entries(),
|
||||
list(mod.linear_constraint_matrix_entries()),
|
||||
)
|
||||
self.assertCountEqual([d], mod.column_nonzeros(x))
|
||||
self.assertCountEqual([], mod.column_nonzeros(y))
|
||||
self.assertCountEqual(
|
||||
[repr(model.LinearTerm(variable=x, coefficient=1.0))],
|
||||
[repr(variables.LinearTerm(variable=x, coefficient=1.0))],
|
||||
[repr(term) for term in d.terms()],
|
||||
)
|
||||
|
||||
def test_linear_constraint_matrix_wrong_model(
|
||||
self, storage_class: StorageClass
|
||||
) -> None:
|
||||
mod1 = model.Model(name="test_model1", storage_class=storage_class)
|
||||
def test_linear_constraint_matrix_wrong_model(self) -> None:
|
||||
mod1 = model.Model(name="test_model1")
|
||||
x1 = mod1.add_binary_variable(name="x")
|
||||
mod2 = model.Model(name="test_model2", storage_class=storage_class)
|
||||
mod2 = model.Model(name="test_model2")
|
||||
mod2.add_binary_variable(name="x")
|
||||
c2 = mod2.add_linear_constraint(lb=0.0, ub=1.0, name="c")
|
||||
with self.assertRaises(ValueError):
|
||||
c2.set_coefficient(x1, 1.0)
|
||||
|
||||
def test_export(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_export(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
mod.objective.offset = 2.0
|
||||
mod.objective.is_maximize = True
|
||||
x = mod.add_binary_variable(name="x")
|
||||
@@ -1022,8 +496,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
)
|
||||
self.assert_protos_equiv(expected, mod.export_model())
|
||||
|
||||
def test_update_tracker_simple(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_update_tracker_simple(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
x = mod.add_binary_variable(name="x")
|
||||
t = mod.add_update_tracker()
|
||||
x.upper_bound = 2.0
|
||||
@@ -1039,8 +513,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
t.advance_checkpoint()
|
||||
self.assertIsNone(t.export_update())
|
||||
|
||||
def test_two_update_trackers(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_two_update_trackers(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
t1 = mod.add_update_tracker()
|
||||
x = mod.add_binary_variable(name="x")
|
||||
t2 = mod.add_update_tracker()
|
||||
@@ -1064,8 +538,8 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
self.assert_protos_equiv(expected1, t1.export_update())
|
||||
self.assert_protos_equiv(expected2, t2.export_update())
|
||||
|
||||
def test_remove_tracker(self, storage_class: StorageClass) -> None:
|
||||
mod = model.Model(name="test_model", storage_class=storage_class)
|
||||
def test_remove_tracker(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
x = mod.add_binary_variable(name="x")
|
||||
t1 = mod.add_update_tracker()
|
||||
t2 = mod.add_update_tracker()
|
||||
@@ -1083,11 +557,11 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase):
|
||||
)
|
||||
)
|
||||
self.assert_protos_equiv(expected, t2.export_update())
|
||||
with self.assertRaises(model_storage.UsedUpdateTrackerAfterRemovalError):
|
||||
with self.assertRaises(ValueError):
|
||||
t1.export_update()
|
||||
with self.assertRaises(model_storage.UsedUpdateTrackerAfterRemovalError):
|
||||
with self.assertRaises(ValueError):
|
||||
t1.advance_checkpoint()
|
||||
with self.assertRaises(KeyError):
|
||||
with self.assertRaises(ValueError):
|
||||
mod.remove_update_tracker(t1)
|
||||
|
||||
|
||||
@@ -1115,6 +589,12 @@ class WrongAttributeTest(absltest.TestCase):
|
||||
with self.assertRaises(AttributeError):
|
||||
mod.objective.matimuze = True # pytype: disable=not-writable
|
||||
|
||||
def test_aux_objective(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
aux = mod.add_auxiliary_objective(priority=1)
|
||||
with self.assertRaises(AttributeError):
|
||||
aux.matimuze = True # pytype: disable=not-writable
|
||||
|
||||
def test_model(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
with self.assertRaises(AttributeError):
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
"""Test normalize for mathopt protos."""
|
||||
|
||||
from google3.net.proto2.contrib.pyutil import compare
|
||||
from absl.testing import absltest
|
||||
from ortools.math_opt import model_parameters_pb2
|
||||
from ortools.math_opt import model_pb2
|
||||
@@ -23,18 +22,21 @@ 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
|
||||
from ortools.math_opt.python.testing import compare_proto
|
||||
|
||||
|
||||
class MathOptProtoAssertionsTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
class MathOptProtoAssertionsTest(
|
||||
compare_proto.MathOptProtoAssertions, absltest.TestCase
|
||||
):
|
||||
|
||||
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())
|
||||
self.assert_protos_equal(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())
|
||||
self.assert_protos_equal(model_with_empty_vars, model_pb2.ModelProto())
|
||||
|
||||
def test_keeps_nonempty_message(self) -> None:
|
||||
model_with_vars = model_pb2.ModelProto()
|
||||
@@ -44,7 +46,7 @@ class MathOptProtoAssertionsTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
expected.variables.ids[:] = [1, 3]
|
||||
|
||||
normalize.math_opt_normalize_proto(model_with_vars)
|
||||
self.assertProto2Equal(model_with_vars, expected)
|
||||
self.assert_protos_equal(model_with_vars, expected)
|
||||
|
||||
def test_keeps_optional_scalar_at_default_message(self) -> None:
|
||||
objective = model_update_pb2.ObjectiveUpdatesProto(offset_update=0.0)
|
||||
@@ -53,17 +55,17 @@ class MathOptProtoAssertionsTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
|
||||
wrong = model_update_pb2.ObjectiveUpdatesProto()
|
||||
with self.assertRaisesRegex(AssertionError, ".*offset_update.*"):
|
||||
self.assertProto2Equal(objective, wrong)
|
||||
self.assert_protos_equal(objective, wrong)
|
||||
|
||||
expected = model_update_pb2.ObjectiveUpdatesProto(offset_update=0.0)
|
||||
self.assertProto2Equal(objective, expected)
|
||||
self.assert_protos_equal(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())
|
||||
self.assert_protos_equal(update_rec_empty, model_update_pb2.ModelUpdateProto())
|
||||
|
||||
def test_duration_no_cleanup(self) -> None:
|
||||
params = parameters_pb2.SolveParametersProto()
|
||||
@@ -71,7 +73,7 @@ class MathOptProtoAssertionsTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
|
||||
with self.assertRaisesRegex(AssertionError, ".*time_limit.*"):
|
||||
normalize.math_opt_normalize_proto(params)
|
||||
self.assertProto2Equal(params, parameters_pb2.SolveParametersProto())
|
||||
self.assert_protos_equal(params, parameters_pb2.SolveParametersProto())
|
||||
|
||||
def test_repeated_scalar_no_cleanup(self) -> None:
|
||||
vec = sparse_containers_pb2.SparseDoubleVectorProto()
|
||||
@@ -81,7 +83,7 @@ class MathOptProtoAssertionsTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
expected = sparse_containers_pb2.SparseDoubleVectorProto()
|
||||
expected.ids[:] = [0, 0]
|
||||
|
||||
self.assertProto2Equal(vec, expected)
|
||||
self.assert_protos_equal(vec, expected)
|
||||
|
||||
def test_reaches_into_map(self) -> None:
|
||||
model = model_pb2.ModelProto()
|
||||
@@ -91,7 +93,7 @@ class MathOptProtoAssertionsTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
expected = model_pb2.ModelProto()
|
||||
expected.quadratic_constraints[2] # pylint: disable=pointless-statement
|
||||
|
||||
self.assertProto2Equal(model, expected)
|
||||
self.assert_protos_equal(model, expected)
|
||||
|
||||
def test_reaches_into_vector(self) -> None:
|
||||
params = model_parameters_pb2.ModelSolveParametersProto()
|
||||
@@ -101,7 +103,7 @@ class MathOptProtoAssertionsTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
expected = model_parameters_pb2.ModelSolveParametersProto()
|
||||
expected.solution_hints.add()
|
||||
|
||||
self.assertProto2Equal(params, expected)
|
||||
self.assert_protos_equal(params, expected)
|
||||
|
||||
def test_oneof_is_not_cleared(self) -> None:
|
||||
result = result_pb2.SolveResultProto()
|
||||
@@ -111,7 +113,7 @@ class MathOptProtoAssertionsTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
expected = result_pb2.SolveResultProto()
|
||||
expected.gscip_output.SetInParent()
|
||||
|
||||
self.assertProto2Equal(result, expected)
|
||||
self.assert_protos_equal(result, expected)
|
||||
|
||||
def test_reaches_into_oneof(self) -> None:
|
||||
result = result_pb2.SolveResultProto()
|
||||
@@ -121,7 +123,7 @@ class MathOptProtoAssertionsTest(absltest.TestCase, compare.Proto2Assertions):
|
||||
expected = result_pb2.SolveResultProto()
|
||||
expected.gscip_output.SetInParent()
|
||||
|
||||
self.assertProto2Equal(result, expected)
|
||||
self.assert_protos_equal(result, expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
287
ortools/math_opt/python/normalized_inequality.py
Normal file
287
ortools/math_opt/python/normalized_inequality.py
Normal file
@@ -0,0 +1,287 @@
|
||||
# Copyright 2010-2025 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 structures for linear and quadratic constraints.
|
||||
|
||||
In contrast to BoundedLinearExpression and related structures, there is no
|
||||
offset inside the inequality.
|
||||
|
||||
This is not part of the MathOpt public API, do not depend on it externally.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import math
|
||||
from typing import Mapping, Optional, Union
|
||||
|
||||
from ortools.math_opt.python import bounded_expressions
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
_BoundedLinearExpressions = (
|
||||
variables.LowerBoundedLinearExpression,
|
||||
variables.UpperBoundedLinearExpression,
|
||||
variables.BoundedLinearExpression,
|
||||
)
|
||||
|
||||
_BoundedQuadraticExpressions = (
|
||||
variables.LowerBoundedLinearExpression,
|
||||
variables.UpperBoundedLinearExpression,
|
||||
variables.BoundedLinearExpression,
|
||||
variables.LowerBoundedQuadraticExpression,
|
||||
variables.UpperBoundedQuadraticExpression,
|
||||
variables.BoundedQuadraticExpression,
|
||||
)
|
||||
|
||||
_BoundedExpressions = (
|
||||
bounded_expressions.LowerBoundedExpression,
|
||||
bounded_expressions.UpperBoundedExpression,
|
||||
bounded_expressions.BoundedExpression,
|
||||
)
|
||||
|
||||
|
||||
def _bool_error() -> TypeError:
|
||||
return TypeError(
|
||||
"Unsupported type for bounded_expr argument:"
|
||||
" bool. This error can occur when trying to add != constraints "
|
||||
"(which are not supported) or inequalities/equalities with constant "
|
||||
"left-hand-side and right-hand-side (which are redundant or make a "
|
||||
"model infeasible)."
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class NormalizedLinearInequality:
|
||||
"""Represents an inequality lb <= expr <= ub where expr's offset is zero."""
|
||||
|
||||
lb: float
|
||||
ub: float
|
||||
coefficients: Mapping[variables.Variable, float]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
lb: Optional[float],
|
||||
ub: Optional[float],
|
||||
expr: Optional[variables.LinearTypes],
|
||||
) -> None:
|
||||
"""Raises a ValueError if expr's offset is infinite."""
|
||||
lb = -math.inf if lb is None else lb
|
||||
ub = math.inf if ub is None else ub
|
||||
expr = 0.0 if expr is None else expr
|
||||
if not isinstance(expr, (int, float, variables.LinearBase)):
|
||||
raise TypeError(
|
||||
f"Unsupported type for expr argument: {type(expr).__name__!r}."
|
||||
)
|
||||
|
||||
flat_expr = variables.as_flat_linear_expression(expr)
|
||||
if math.isinf(flat_expr.offset):
|
||||
raise ValueError(
|
||||
"Trying to create a linear constraint whose expression has an"
|
||||
" infinite offset."
|
||||
)
|
||||
self.lb = lb - flat_expr.offset
|
||||
self.ub = ub - flat_expr.offset
|
||||
self.coefficients = flat_expr.terms
|
||||
|
||||
|
||||
def _normalize_bounded_linear_expression(
|
||||
bounded_expr: variables.BoundedLinearTypes,
|
||||
) -> NormalizedLinearInequality:
|
||||
"""Converts a bounded linear expression into a NormalizedLinearInequality."""
|
||||
if isinstance(bounded_expr, variables.VarEqVar):
|
||||
return NormalizedLinearInequality(
|
||||
lb=0.0,
|
||||
ub=0.0,
|
||||
expr=bounded_expr.first_variable - bounded_expr.second_variable,
|
||||
)
|
||||
elif isinstance(bounded_expr, _BoundedExpressions):
|
||||
if isinstance(bounded_expr.expression, (int, float, variables.LinearBase)):
|
||||
return NormalizedLinearInequality(
|
||||
lb=bounded_expr.lower_bound,
|
||||
ub=bounded_expr.upper_bound,
|
||||
expr=bounded_expr.expression,
|
||||
)
|
||||
else:
|
||||
raise TypeError(
|
||||
"Bad type of expression in bounded_expr:"
|
||||
f" {type(bounded_expr.expression).__name__!r}."
|
||||
)
|
||||
else:
|
||||
raise TypeError(f"bounded_expr has bad type: {type(bounded_expr).__name__!r}.")
|
||||
|
||||
|
||||
# TODO(b/227214976): Update the note below and link to pytype bug number.
|
||||
# Note: bounded_expr's type includes bool only as a workaround to a pytype
|
||||
# issue. Passing a bool for bounded_expr will raise an error in runtime.
|
||||
def as_normalized_linear_inequality(
|
||||
bounded_expr: Optional[Union[bool, variables.BoundedLinearTypes]] = None,
|
||||
*,
|
||||
lb: Optional[float] = None,
|
||||
ub: Optional[float] = None,
|
||||
expr: Optional[variables.LinearTypes] = None,
|
||||
) -> NormalizedLinearInequality:
|
||||
"""Builds a NormalizedLinearInequality.
|
||||
|
||||
If bounded_expr is not None, then all other arguments must be None.
|
||||
|
||||
If expr has a nonzero offset, it will be subtracted from both lb and ub.
|
||||
|
||||
When bounded_expr is unset and a named argument is unset, we use the defaults:
|
||||
* lb: -math.inf
|
||||
* ub: math.inf
|
||||
* expr: 0
|
||||
|
||||
Args:
|
||||
bounded_expr: a linear inequality describing the constraint.
|
||||
lb: The lower bound when bounded_expr is None.
|
||||
ub: The upper bound if bounded_expr is None.
|
||||
expr: The expression when bounded_expr is None.
|
||||
|
||||
Returns:
|
||||
A NormalizedLinearInequality representing the linear constraint.
|
||||
"""
|
||||
if isinstance(bounded_expr, bool):
|
||||
raise _bool_error()
|
||||
if bounded_expr is not None:
|
||||
if lb is not None:
|
||||
raise AssertionError(
|
||||
"lb cannot be specified when bounded_expr is not None."
|
||||
)
|
||||
if ub is not None:
|
||||
raise AssertionError(
|
||||
"ub cannot be specified when bounded_expr is not None."
|
||||
)
|
||||
if expr is not None:
|
||||
raise AssertionError(
|
||||
"expr cannot be specified when bounded_expr is not None"
|
||||
)
|
||||
return _normalize_bounded_linear_expression(bounded_expr)
|
||||
# Note: NormalizedLinearInequality() will runtime check the type of expr.
|
||||
return NormalizedLinearInequality(lb=lb, ub=ub, expr=expr)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class NormalizedQuadraticInequality:
|
||||
"""Represents an inequality lb <= expr <= ub where expr's offset is zero."""
|
||||
|
||||
lb: float
|
||||
ub: float
|
||||
linear_coefficients: Mapping[variables.Variable, float]
|
||||
quadratic_coefficients: Mapping[variables.QuadraticTermKey, float]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
lb: Optional[float] = None,
|
||||
ub: Optional[float] = None,
|
||||
expr: Optional[variables.QuadraticTypes] = None,
|
||||
) -> None:
|
||||
"""Raises a ValueError if expr's offset is infinite."""
|
||||
lb = -math.inf if lb is None else lb
|
||||
ub = math.inf if ub is None else ub
|
||||
expr = 0.0 if expr is None else expr
|
||||
if not isinstance(
|
||||
expr, (int, float, variables.LinearBase, variables.QuadraticBase)
|
||||
):
|
||||
raise TypeError(
|
||||
f"Unsupported type for expr argument: {type(expr).__name__!r}."
|
||||
)
|
||||
flat_expr = variables.as_flat_quadratic_expression(expr)
|
||||
if math.isinf(flat_expr.offset):
|
||||
raise ValueError(
|
||||
"Trying to create a quadratic constraint whose expression has an"
|
||||
" infinite offset."
|
||||
)
|
||||
self.lb = lb - flat_expr.offset
|
||||
self.ub = ub - flat_expr.offset
|
||||
self.linear_coefficients = flat_expr.linear_terms
|
||||
self.quadratic_coefficients = flat_expr.quadratic_terms
|
||||
|
||||
|
||||
def _normalize_bounded_quadratic_expression(
|
||||
bounded_expr: Union[variables.BoundedQuadraticTypes, variables.BoundedLinearTypes],
|
||||
) -> NormalizedQuadraticInequality:
|
||||
"""Converts a bounded quadratic expression into a NormalizedQuadraticInequality."""
|
||||
if isinstance(bounded_expr, variables.VarEqVar):
|
||||
return NormalizedQuadraticInequality(
|
||||
lb=0.0,
|
||||
ub=0.0,
|
||||
expr=bounded_expr.first_variable - bounded_expr.second_variable,
|
||||
)
|
||||
elif isinstance(bounded_expr, _BoundedExpressions):
|
||||
if isinstance(
|
||||
bounded_expr.expression,
|
||||
(int, float, variables.LinearBase, variables.QuadraticBase),
|
||||
):
|
||||
return NormalizedQuadraticInequality(
|
||||
lb=bounded_expr.lower_bound,
|
||||
ub=bounded_expr.upper_bound,
|
||||
expr=bounded_expr.expression,
|
||||
)
|
||||
else:
|
||||
raise TypeError(
|
||||
"bounded_expr.expression has bad type:"
|
||||
f" {type(bounded_expr.expression).__name__!r}."
|
||||
)
|
||||
else:
|
||||
raise TypeError(f"bounded_expr has bad type: {type(bounded_expr).__name__!r}.")
|
||||
|
||||
|
||||
# TODO(b/227214976): Update the note below and link to pytype bug number.
|
||||
# Note: bounded_expr's type includes bool only as a workaround to a pytype
|
||||
# issue. Passing a bool for bounded_expr will raise an error in runtime.
|
||||
def as_normalized_quadratic_inequality(
|
||||
bounded_expr: Optional[
|
||||
Union[bool, variables.BoundedLinearTypes, variables.BoundedQuadraticTypes]
|
||||
] = None,
|
||||
*,
|
||||
lb: Optional[float] = None,
|
||||
ub: Optional[float] = None,
|
||||
expr: Optional[variables.QuadraticTypes] = None,
|
||||
) -> NormalizedQuadraticInequality:
|
||||
"""Builds a NormalizedLinearInequality.
|
||||
|
||||
If bounded_expr is not None, then all other arguments must be None.
|
||||
|
||||
If expr has a nonzero offset, it will be subtracted from both lb and ub.
|
||||
|
||||
When bounded_expr is unset and a named argument is unset, we use the defaults:
|
||||
* lb: -math.inf
|
||||
* ub: math.inf
|
||||
* expr: 0
|
||||
|
||||
Args:
|
||||
bounded_expr: a quadratic inequality describing the constraint.
|
||||
lb: The lower bound when bounded_expr is None.
|
||||
ub: The upper bound if bounded_expr is None.
|
||||
expr: The expression when bounded_expr is None.
|
||||
|
||||
Returns:
|
||||
A NormalizedLinearInequality representing the linear constraint.
|
||||
"""
|
||||
if isinstance(bounded_expr, bool):
|
||||
raise _bool_error()
|
||||
if bounded_expr is not None:
|
||||
if lb is not None:
|
||||
raise AssertionError(
|
||||
"lb cannot be specified when bounded_expr is not None."
|
||||
)
|
||||
if ub is not None:
|
||||
raise AssertionError(
|
||||
"ub cannot be specified when bounded_expr is not None."
|
||||
)
|
||||
if expr is not None:
|
||||
raise AssertionError(
|
||||
"expr cannot be specified when bounded_expr is not None"
|
||||
)
|
||||
return _normalize_bounded_quadratic_expression(bounded_expr)
|
||||
return NormalizedQuadraticInequality(lb=lb, ub=ub, expr=expr)
|
||||
293
ortools/math_opt/python/normalized_inequality_test.py
Normal file
293
ortools/math_opt/python/normalized_inequality_test.py
Normal file
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2010-2025 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 Dict, Tuple
|
||||
|
||||
from absl.testing import absltest
|
||||
from ortools.math_opt.python import bounded_expressions
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import normalized_inequality
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
|
||||
class NormalizedLinearInequalityTest(absltest.TestCase):
|
||||
|
||||
def test_init_all_present(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
inequality = normalized_inequality.NormalizedLinearInequality(
|
||||
lb=-4.0, expr=2.0 * x + 3.0, ub=8.0
|
||||
)
|
||||
self.assertAlmostEqual(inequality.lb, -7.0, delta=1e-10)
|
||||
self.assertAlmostEqual(inequality.ub, 5.0, delta=1e-10)
|
||||
self.assertEqual(inequality.coefficients, {x: 2.0})
|
||||
|
||||
def test_init_all_missing(self) -> None:
|
||||
inequality = normalized_inequality.NormalizedLinearInequality(
|
||||
lb=None, expr=None, ub=None
|
||||
)
|
||||
self.assertEqual(inequality.lb, -math.inf)
|
||||
self.assertEqual(inequality.ub, math.inf)
|
||||
self.assertEmpty(inequality.coefficients)
|
||||
|
||||
def test_init_offset_only(self) -> None:
|
||||
inequality = normalized_inequality.NormalizedLinearInequality(
|
||||
lb=None, expr=2.0, ub=None
|
||||
)
|
||||
self.assertEqual(inequality.lb, -math.inf)
|
||||
self.assertEqual(inequality.ub, math.inf)
|
||||
self.assertEmpty(inequality.coefficients)
|
||||
|
||||
def test_init_infinite_offset_error(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "infinite"):
|
||||
normalized_inequality.NormalizedLinearInequality(
|
||||
lb=1.0, expr=math.inf, ub=2.0
|
||||
)
|
||||
|
||||
def test_init_expr_wrong_type_error(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
with self.assertRaises(TypeError):
|
||||
normalized_inequality.NormalizedLinearInequality(
|
||||
lb=1.0, expr=x * x, ub=2.0
|
||||
) # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_as_normalized_inequality_from_parts(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
inequality = normalized_inequality.as_normalized_linear_inequality(
|
||||
lb=-4.0, expr=2.0 * x + 3.0, ub=8.0
|
||||
)
|
||||
self.assertAlmostEqual(inequality.lb, -7.0, delta=1e-10)
|
||||
self.assertAlmostEqual(inequality.ub, 5.0, delta=1e-10)
|
||||
self.assertEqual(inequality.coefficients, {x: 2.0})
|
||||
|
||||
def test_as_normalized_inequality_from_none(self) -> None:
|
||||
inequality = normalized_inequality.as_normalized_linear_inequality()
|
||||
self.assertEqual(inequality.lb, -math.inf)
|
||||
self.assertEqual(inequality.ub, math.inf)
|
||||
self.assertEmpty(inequality.coefficients)
|
||||
|
||||
def test_as_normalized_inequality_from_var_eq_var(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
inequality = normalized_inequality.as_normalized_linear_inequality(x == y)
|
||||
self.assertEqual(inequality.lb, 0.0)
|
||||
self.assertEqual(inequality.ub, 0.0)
|
||||
self.assertEqual(inequality.coefficients.keys(), {x, y})
|
||||
self.assertEqual(set(inequality.coefficients.values()), {-1.0, 1.0})
|
||||
|
||||
def test_as_normalized_inequality_from_upper_bounded_expr(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
inequality = normalized_inequality.as_normalized_linear_inequality(
|
||||
3.0 * x + y <= 2.0
|
||||
)
|
||||
self.assertEqual(inequality.lb, -math.inf)
|
||||
self.assertEqual(inequality.ub, 2.0)
|
||||
self.assertEqual(inequality.coefficients, {x: 3.0, y: 1.0})
|
||||
|
||||
def test_as_normalized_inequality_from_lower_bounded_expr(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
inequality = normalized_inequality.as_normalized_linear_inequality(
|
||||
2.0 <= 3.0 * x + y
|
||||
)
|
||||
self.assertEqual(inequality.lb, 2.0)
|
||||
self.assertEqual(inequality.ub, math.inf)
|
||||
self.assertEqual(inequality.coefficients, {x: 3.0, y: 1.0})
|
||||
|
||||
def test_lb_and_bounded_expr_error(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
with self.assertRaisesRegex(AssertionError, "lb cannot be specified"):
|
||||
normalized_inequality.as_normalized_linear_inequality(x <= 1.0, lb=-3.0)
|
||||
|
||||
def test_ub_and_bounded_expr_error(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
with self.assertRaisesRegex(AssertionError, "ub cannot be specified"):
|
||||
normalized_inequality.as_normalized_linear_inequality(x <= 1.0, ub=-3.0)
|
||||
|
||||
def test_expr_and_bounded_expr_error(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
with self.assertRaisesRegex(AssertionError, "expr cannot be specified"):
|
||||
normalized_inequality.as_normalized_linear_inequality(x <= 1.0, expr=3 * x)
|
||||
|
||||
def test_bounded_expr_bad_type_raise_error(self) -> None:
|
||||
with self.assertRaisesRegex(TypeError, "bounded_expr has bad type"):
|
||||
normalized_inequality.as_normalized_linear_inequality(
|
||||
"dogdog"
|
||||
) # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_bounded_expr_inner_expr_bad_type_raise_error(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
TypeError, "Bad type of expression in bounded_expr"
|
||||
):
|
||||
bounded = bounded_expressions.BoundedExpression(
|
||||
lower_bound=1.0, expression="dogdog", upper_bound=1.0
|
||||
)
|
||||
normalized_inequality.as_normalized_linear_inequality(
|
||||
bounded
|
||||
) # pytype: disable=wrong-arg-types
|
||||
|
||||
|
||||
def _quad_coef_dict(
|
||||
quad_inequality: normalized_inequality.NormalizedQuadraticInequality,
|
||||
) -> Dict[Tuple[variables.Variable, variables.Variable], float]:
|
||||
return {
|
||||
(key.first_var, key.second_var): coef
|
||||
for (key, coef) in quad_inequality.quadratic_coefficients.items()
|
||||
}
|
||||
|
||||
|
||||
class NormalizedQuadraticInequalityTest(absltest.TestCase):
|
||||
|
||||
def test_init_all_present(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
inequality = normalized_inequality.NormalizedQuadraticInequality(
|
||||
lb=-4.0, expr=4.0 * x * x + 2.0 * x + 3.0, ub=8.0
|
||||
)
|
||||
self.assertAlmostEqual(inequality.lb, -7.0, delta=1e-10)
|
||||
self.assertAlmostEqual(inequality.ub, 5.0, delta=1e-10)
|
||||
self.assertEqual(inequality.linear_coefficients, {x: 2.0})
|
||||
self.assertEqual(_quad_coef_dict(inequality), {(x, x): 4.0})
|
||||
|
||||
def test_init_all_missing(self) -> None:
|
||||
inequality = normalized_inequality.NormalizedQuadraticInequality(
|
||||
lb=None, expr=None, ub=None
|
||||
)
|
||||
self.assertEqual(inequality.lb, -math.inf)
|
||||
self.assertEqual(inequality.ub, math.inf)
|
||||
self.assertEmpty(inequality.linear_coefficients)
|
||||
self.assertEmpty(inequality.quadratic_coefficients)
|
||||
|
||||
def test_init_offset_only(self) -> None:
|
||||
inequality = normalized_inequality.NormalizedQuadraticInequality(
|
||||
lb=None, expr=2.0, ub=None
|
||||
)
|
||||
self.assertEqual(inequality.lb, -math.inf)
|
||||
self.assertEqual(inequality.ub, math.inf)
|
||||
self.assertEmpty(inequality.linear_coefficients)
|
||||
self.assertEmpty(inequality.quadratic_coefficients)
|
||||
|
||||
def test_init_infinite_offset_error(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "infinite"):
|
||||
normalized_inequality.NormalizedQuadraticInequality(
|
||||
lb=1.0, expr=math.inf, ub=2.0
|
||||
)
|
||||
|
||||
def test_init_expr_wrong_type_error(self) -> None:
|
||||
with self.assertRaises(TypeError):
|
||||
normalized_inequality.NormalizedQuadraticInequality(
|
||||
lb=1.0, expr="dog", ub=2.0
|
||||
) # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_as_normalized_inequality_from_parts(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
inequality = normalized_inequality.as_normalized_quadratic_inequality(
|
||||
lb=-4.0, expr=4.0 * x * x + 2.0 * x + 3.0, ub=8.0
|
||||
)
|
||||
self.assertAlmostEqual(inequality.lb, -7.0, delta=1e-10)
|
||||
self.assertAlmostEqual(inequality.ub, 5.0, delta=1e-10)
|
||||
self.assertEqual(inequality.linear_coefficients, {x: 2.0})
|
||||
self.assertEqual(_quad_coef_dict(inequality), {(x, x): 4.0})
|
||||
|
||||
def test_as_normalized_inequality_from_none(self) -> None:
|
||||
inequality = normalized_inequality.as_normalized_quadratic_inequality()
|
||||
self.assertEqual(inequality.lb, -math.inf)
|
||||
self.assertEqual(inequality.ub, math.inf)
|
||||
self.assertEmpty(inequality.linear_coefficients)
|
||||
self.assertEmpty(inequality.quadratic_coefficients)
|
||||
|
||||
def test_as_normalized_inequality_from_var_eq_var(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
inequality = normalized_inequality.as_normalized_quadratic_inequality(x == y)
|
||||
self.assertEqual(inequality.lb, 0.0)
|
||||
self.assertEqual(inequality.ub, 0.0)
|
||||
self.assertEqual(inequality.linear_coefficients.keys(), {x, y})
|
||||
self.assertEqual(set(inequality.linear_coefficients.values()), {-1.0, 1.0})
|
||||
self.assertEmpty(inequality.quadratic_coefficients)
|
||||
|
||||
def test_as_normalized_inequality_from_upper_bounded_expr(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
inequality = normalized_inequality.as_normalized_quadratic_inequality(
|
||||
4.0 * x * x + 3.0 * x + y <= 2.0
|
||||
)
|
||||
self.assertEqual(inequality.lb, -math.inf)
|
||||
self.assertEqual(inequality.ub, 2.0)
|
||||
self.assertEqual(inequality.linear_coefficients, {x: 3.0, y: 1.0})
|
||||
self.assertEqual(_quad_coef_dict(inequality), {(x, x): 4.0})
|
||||
|
||||
def test_as_normalized_inequality_from_lower_bounded_expr(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
inequality = normalized_inequality.as_normalized_quadratic_inequality(
|
||||
2.0 <= 4.0 * x * x + 3.0 * x + y
|
||||
)
|
||||
self.assertEqual(inequality.lb, 2.0)
|
||||
self.assertEqual(inequality.ub, math.inf)
|
||||
self.assertEqual(inequality.linear_coefficients, {x: 3.0, y: 1.0})
|
||||
self.assertEqual(_quad_coef_dict(inequality), {(x, x): 4.0})
|
||||
|
||||
def test_lb_and_boundex_expr_error(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
with self.assertRaisesRegex(AssertionError, "lb cannot be specified"):
|
||||
normalized_inequality.as_normalized_quadratic_inequality(x <= 1.0, lb=-3.0)
|
||||
|
||||
def test_ub_and_boundex_expr_error(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
with self.assertRaisesRegex(AssertionError, "ub cannot be specified"):
|
||||
normalized_inequality.as_normalized_quadratic_inequality(x <= 1.0, ub=-3.0)
|
||||
|
||||
def test_expr_and_boundex_expr_error(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
with self.assertRaisesRegex(AssertionError, "expr cannot be specified"):
|
||||
normalized_inequality.as_normalized_quadratic_inequality(
|
||||
x <= 1.0, expr=3 * x
|
||||
)
|
||||
|
||||
def test_bounded_expr_bad_type_raise_error(self) -> None:
|
||||
with self.assertRaisesRegex(TypeError, "bounded_expr has bad type"):
|
||||
normalized_inequality.as_normalized_quadratic_inequality(
|
||||
"dogdog"
|
||||
) # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_bounded_expr_inner_expr_bad_type_raise_error(self) -> None:
|
||||
with self.assertRaisesRegex(TypeError, "bounded_expr.expression has bad type"):
|
||||
bounded = bounded_expressions.BoundedExpression(
|
||||
lower_bound=1.0, expression="dogdog", upper_bound=1.0
|
||||
)
|
||||
normalized_inequality.as_normalized_quadratic_inequality(
|
||||
bounded
|
||||
) # pytype: disable=wrong-arg-types
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
545
ortools/math_opt/python/objectives.py
Normal file
545
ortools/math_opt/python/objectives.py
Normal file
@@ -0,0 +1,545 @@
|
||||
# Copyright 2010-2025 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 Objective for a MathOpt optimization model."""
|
||||
|
||||
import abc
|
||||
from typing import Any, Iterator
|
||||
|
||||
from ortools.math_opt.elemental.python import enums
|
||||
from ortools.math_opt.python import from_model
|
||||
from ortools.math_opt.python import variables
|
||||
from ortools.math_opt.python.elemental import elemental
|
||||
|
||||
|
||||
class Objective(from_model.FromModel, metaclass=abc.ABCMeta):
|
||||
"""The objective for an optimization model.
|
||||
|
||||
An objective is either of the form:
|
||||
min o + sum_{i in I} c_i * x_i + sum_{i, j in I, i <= j} q_i,j * x_i * x_j
|
||||
or
|
||||
max o + sum_{i in I} c_i * x_i + sum_{(i, j) in Q} q_i,j * x_i * x_j
|
||||
where x_i are the decision variables of the problem and where all pairs (i, j)
|
||||
in Q satisfy i <= j. The values of o, c_i and q_i,j should be finite and not
|
||||
NaN.
|
||||
|
||||
The objective can be configured as follows:
|
||||
* offset: a float property, o above. Should be finite and not NaN.
|
||||
* is_maximize: a bool property, if the objective is to maximize or minimize.
|
||||
* set_linear_coefficient and get_linear_coefficient control the c_i * x_i
|
||||
terms. The variables must be from the same model as this objective, and
|
||||
the c_i must be finite and not NaN. The coefficient for any variable not
|
||||
set is 0.0, and setting a coefficient to 0.0 removes it from I above.
|
||||
* set_quadratic_coefficient and get_quadratic_coefficient control the
|
||||
q_i,j * x_i * x_j terms. The variables must be from the same model as this
|
||||
objective, and the q_i,j must be finite and not NaN. The coefficient for
|
||||
any pair of variables not set is 0.0, and setting a coefficient to 0.0
|
||||
removes the associated (i,j) from Q above.
|
||||
|
||||
Do not create an Objective directly, use Model.objective to access the
|
||||
objective instead (or Model.add_auxiliary_objective()). Two Objective objects
|
||||
can represent the same objective (for the same model). They will have the same
|
||||
underlying Objective.elemental for storing the data. The Objective class is
|
||||
simply a reference to an Elemental.
|
||||
|
||||
The objective is linear if only linear coefficients are set. This can be
|
||||
useful to avoid solve-time errors with solvers that do not accept quadratic
|
||||
objectives. To facilitate this linear objective guarantee we provide three
|
||||
functions to add to the objective:
|
||||
* add(), which accepts linear or quadratic expressions,
|
||||
* add_quadratic(), which also accepts linear or quadratic expressions and
|
||||
can be used to signal a quadratic objective is possible, and
|
||||
* add_linear(), which only accepts linear expressions and can be used to
|
||||
guarantee the objective remains linear.
|
||||
|
||||
For quadratic terms, the order that variables are provided does not matter,
|
||||
we always canonicalize to first_var <= second_var. So if you set (x1, x2) to 7
|
||||
then:
|
||||
* getting (x2, x1) returns 7
|
||||
* setting (x2, x1) to 10 overwrites the value of 7.
|
||||
Likewise, when we return nonzero quadratic coefficients, we always use the
|
||||
form first_var <= second_var.
|
||||
|
||||
Most problems have only a single objective, but hierarchical objectives are
|
||||
supported (see Model.add_auxiliary_objective()). Note that quadratic Auxiliary
|
||||
objectives are not supported.
|
||||
"""
|
||||
|
||||
__slots__ = ("_elemental",)
|
||||
|
||||
def __init__(self, elem: elemental.Elemental) -> None:
|
||||
"""Do not invoke directly, prefer Model.objective."""
|
||||
self._elemental: elemental.Elemental = elem
|
||||
|
||||
@property
|
||||
def elemental(self) -> elemental.Elemental:
|
||||
"""The underlying data structure for the model, for internal use only."""
|
||||
return self._elemental
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def name(self) -> str:
|
||||
"""The immutable name of this objective, for display only."""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def is_maximize(self) -> bool:
|
||||
"""If true, the direction is maximization, otherwise minimization."""
|
||||
|
||||
@is_maximize.setter
|
||||
@abc.abstractmethod
|
||||
def is_maximize(self, is_maximize: bool) -> None: ...
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def offset(self) -> float:
|
||||
"""A constant added to the objective."""
|
||||
|
||||
@offset.setter
|
||||
@abc.abstractmethod
|
||||
def offset(self, value: float) -> None: ...
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def priority(self) -> int:
|
||||
"""For hierarchical problems, determines the order to apply objectives.
|
||||
|
||||
The objectives are applied from lowest priority to highest.
|
||||
|
||||
The default priority for the primary objective is zero, and auxiliary
|
||||
objectives must specific a priority at creation time.
|
||||
|
||||
Priority has no effect for problems with only one objective.
|
||||
"""
|
||||
|
||||
@priority.setter
|
||||
@abc.abstractmethod
|
||||
def priority(self, value: int) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_linear_coefficient(self, var: variables.Variable, coef: float) -> None:
|
||||
"""Sets the coefficient of `var` to `coef` in the objective."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_linear_coefficient(self, var: variables.Variable) -> float:
|
||||
"""Returns the coefficinet of `var` (or zero if unset)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def linear_terms(self) -> Iterator[variables.LinearTerm]:
|
||||
"""Yields variable coefficient pairs for variables with nonzero objective coefficient in undefined order."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_quadratic_coefficient(
|
||||
self,
|
||||
first_variable: variables.Variable,
|
||||
second_variable: variables.Variable,
|
||||
coef: float,
|
||||
) -> None:
|
||||
"""Sets the coefficient for product of variables (see class description)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_quadratic_coefficient(
|
||||
self,
|
||||
first_variable: variables.Variable,
|
||||
second_variable: variables.Variable,
|
||||
) -> float:
|
||||
"""Gets the coefficient for product of variables (see class description)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def quadratic_terms(self) -> Iterator[variables.QuadraticTerm]:
|
||||
"""Yields quadratic terms with nonzero objective coefficient in undefined order."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def clear(self) -> None:
|
||||
"""Clears objective coefficients and offset. Does not change direction."""
|
||||
|
||||
def as_linear_expression(self) -> variables.LinearExpression:
|
||||
"""Returns an equivalent LinearExpression, or errors if quadratic."""
|
||||
if any(self.quadratic_terms()):
|
||||
raise TypeError("Cannot get a quadratic objective as a linear expression")
|
||||
return variables.as_flat_linear_expression(
|
||||
self.offset + variables.LinearSum(self.linear_terms())
|
||||
)
|
||||
|
||||
def as_quadratic_expression(self) -> variables.QuadraticExpression:
|
||||
"""Returns an equivalent QuadraticExpression to this objetive."""
|
||||
return variables.as_flat_quadratic_expression(
|
||||
self.offset
|
||||
+ variables.LinearSum(self.linear_terms())
|
||||
+ variables.QuadraticSum(self.quadratic_terms())
|
||||
)
|
||||
|
||||
def add(self, objective: variables.QuadraticTypes) -> None:
|
||||
"""Adds the provided expression `objective` to the objective function.
|
||||
|
||||
For a compile time guarantee that the objective remains linear, use
|
||||
add_linear() instead.
|
||||
|
||||
Args:
|
||||
objective: the expression to add to the objective function.
|
||||
"""
|
||||
if isinstance(objective, (variables.LinearBase, int, float)):
|
||||
self.add_linear(objective)
|
||||
elif isinstance(objective, variables.QuadraticBase):
|
||||
self.add_quadratic(objective)
|
||||
else:
|
||||
raise TypeError(
|
||||
"unsupported type in objective argument for "
|
||||
f"Objective.add(): {type(objective).__name__!r}"
|
||||
)
|
||||
|
||||
def add_linear(self, objective: variables.LinearTypes) -> None:
|
||||
"""Adds the provided linear expression `objective` to the objective function."""
|
||||
if not isinstance(objective, (variables.LinearBase, int, float)):
|
||||
raise TypeError(
|
||||
"unsupported type in objective argument for "
|
||||
f"Objective.add_linear(): {type(objective).__name__!r}"
|
||||
)
|
||||
objective_expr = variables.as_flat_linear_expression(objective)
|
||||
self.offset += objective_expr.offset
|
||||
for var, coefficient in objective_expr.terms.items():
|
||||
self.set_linear_coefficient(
|
||||
var, self.get_linear_coefficient(var) + coefficient
|
||||
)
|
||||
|
||||
def add_quadratic(self, objective: variables.QuadraticTypes) -> None:
|
||||
"""Adds the provided quadratic expression `objective` to the objective function."""
|
||||
if not isinstance(
|
||||
objective, (variables.QuadraticBase, variables.LinearBase, int, float)
|
||||
):
|
||||
raise TypeError(
|
||||
"unsupported type in objective argument for "
|
||||
f"Objective.add(): {type(objective).__name__!r}"
|
||||
)
|
||||
objective_expr = variables.as_flat_quadratic_expression(objective)
|
||||
self.offset += objective_expr.offset
|
||||
for var, coefficient in objective_expr.linear_terms.items():
|
||||
self.set_linear_coefficient(
|
||||
var, self.get_linear_coefficient(var) + coefficient
|
||||
)
|
||||
for key, coefficient in objective_expr.quadratic_terms.items():
|
||||
self.set_quadratic_coefficient(
|
||||
key.first_var,
|
||||
key.second_var,
|
||||
self.get_quadratic_coefficient(key.first_var, key.second_var)
|
||||
+ coefficient,
|
||||
)
|
||||
|
||||
def set_to_linear_expression(self, linear_expr: variables.LinearTypes) -> None:
|
||||
"""Sets the objective to optimize to `linear_expr`."""
|
||||
if not isinstance(linear_expr, (variables.LinearBase, int, float)):
|
||||
raise TypeError(
|
||||
"unsupported type in objective argument for "
|
||||
f"set_to_linear_expression: {type(linear_expr).__name__!r}"
|
||||
)
|
||||
self.clear()
|
||||
objective_expr = variables.as_flat_linear_expression(linear_expr)
|
||||
self.offset = objective_expr.offset
|
||||
for var, coefficient in objective_expr.terms.items():
|
||||
self.set_linear_coefficient(var, coefficient)
|
||||
|
||||
def set_to_quadratic_expression(
|
||||
self, quadratic_expr: variables.QuadraticTypes
|
||||
) -> None:
|
||||
"""Sets the objective to optimize the `quadratic_expr`."""
|
||||
if not isinstance(
|
||||
quadratic_expr,
|
||||
(variables.QuadraticBase, variables.LinearBase, int, float),
|
||||
):
|
||||
raise TypeError(
|
||||
"unsupported type in objective argument for "
|
||||
f"set_to_quadratic_expression: {type(quadratic_expr).__name__!r}"
|
||||
)
|
||||
self.clear()
|
||||
objective_expr = variables.as_flat_quadratic_expression(quadratic_expr)
|
||||
self.offset = objective_expr.offset
|
||||
for var, coefficient in objective_expr.linear_terms.items():
|
||||
self.set_linear_coefficient(var, coefficient)
|
||||
for quad_key, coefficient in objective_expr.quadratic_terms.items():
|
||||
self.set_quadratic_coefficient(
|
||||
quad_key.first_var, quad_key.second_var, coefficient
|
||||
)
|
||||
|
||||
def set_to_expression(self, expr: variables.QuadraticTypes) -> None:
|
||||
"""Sets the objective to optimize the `expr`."""
|
||||
if isinstance(expr, (variables.LinearBase, int, float)):
|
||||
self.set_to_linear_expression(expr)
|
||||
elif isinstance(expr, variables.QuadraticBase):
|
||||
self.set_to_quadratic_expression(expr)
|
||||
else:
|
||||
raise TypeError(
|
||||
"unsupported type in objective argument for "
|
||||
f"set_to_expression: {type(expr).__name__!r}"
|
||||
)
|
||||
|
||||
|
||||
class PrimaryObjective(Objective):
|
||||
"""The main objective, but users should program against Objective directly."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._elemental.primary_objective_name
|
||||
|
||||
@property
|
||||
def is_maximize(self) -> bool:
|
||||
return self._elemental.get_attr(enums.BoolAttr0.MAXIMIZE, ())
|
||||
|
||||
@is_maximize.setter
|
||||
def is_maximize(self, is_maximize: bool) -> None:
|
||||
self._elemental.set_attr(enums.BoolAttr0.MAXIMIZE, (), is_maximize)
|
||||
|
||||
@property
|
||||
def offset(self) -> float:
|
||||
return self._elemental.get_attr(enums.DoubleAttr0.OBJECTIVE_OFFSET, ())
|
||||
|
||||
@offset.setter
|
||||
def offset(self, value: float) -> None:
|
||||
self._elemental.set_attr(enums.DoubleAttr0.OBJECTIVE_OFFSET, (), value)
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return self._elemental.get_attr(enums.IntAttr0.OBJECTIVE_PRIORITY, ())
|
||||
|
||||
@priority.setter
|
||||
def priority(self, value: int) -> None:
|
||||
self._elemental.set_attr(enums.IntAttr0.OBJECTIVE_PRIORITY, (), value)
|
||||
|
||||
def set_linear_coefficient(self, var: variables.Variable, coef: float) -> None:
|
||||
from_model.model_is_same(self, var)
|
||||
self._elemental.set_attr(
|
||||
enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT, (var.id,), coef
|
||||
)
|
||||
|
||||
def get_linear_coefficient(self, var: variables.Variable) -> float:
|
||||
from_model.model_is_same(self, var)
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT, (var.id,)
|
||||
)
|
||||
|
||||
def linear_terms(self) -> Iterator[variables.LinearTerm]:
|
||||
keys = self._elemental.get_attr_non_defaults(
|
||||
enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT
|
||||
)
|
||||
var_index = 0
|
||||
coefs = self._elemental.get_attrs(
|
||||
enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT, keys
|
||||
)
|
||||
for i in range(len(keys)):
|
||||
yield variables.LinearTerm(
|
||||
variable=variables.Variable(self._elemental, int(keys[i, var_index])),
|
||||
coefficient=float(coefs[i]),
|
||||
)
|
||||
|
||||
def set_quadratic_coefficient(
|
||||
self,
|
||||
first_variable: variables.Variable,
|
||||
second_variable: variables.Variable,
|
||||
coef: float,
|
||||
) -> None:
|
||||
from_model.model_is_same(self, first_variable)
|
||||
from_model.model_is_same(self, second_variable)
|
||||
self._elemental.set_attr(
|
||||
enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT,
|
||||
(first_variable.id, second_variable.id),
|
||||
coef,
|
||||
)
|
||||
|
||||
def get_quadratic_coefficient(
|
||||
self,
|
||||
first_variable: variables.Variable,
|
||||
second_variable: variables.Variable,
|
||||
) -> float:
|
||||
from_model.model_is_same(self, first_variable)
|
||||
from_model.model_is_same(self, second_variable)
|
||||
return self._elemental.get_attr(
|
||||
enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT,
|
||||
(first_variable.id, second_variable.id),
|
||||
)
|
||||
|
||||
def quadratic_terms(self) -> Iterator[variables.QuadraticTerm]:
|
||||
keys = self._elemental.get_attr_non_defaults(
|
||||
enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT
|
||||
)
|
||||
coefs = self._elemental.get_attrs(
|
||||
enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT, keys
|
||||
)
|
||||
for i in range(len(keys)):
|
||||
yield variables.QuadraticTerm(
|
||||
variables.QuadraticTermKey(
|
||||
variables.Variable(self._elemental, int(keys[i, 0])),
|
||||
variables.Variable(self._elemental, int(keys[i, 1])),
|
||||
),
|
||||
coefficient=float(coefs[i]),
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._elemental.clear_attr(enums.DoubleAttr0.OBJECTIVE_OFFSET)
|
||||
self._elemental.clear_attr(enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT)
|
||||
self._elemental.clear_attr(
|
||||
enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT
|
||||
)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if isinstance(other, PrimaryObjective):
|
||||
return self._elemental is other._elemental
|
||||
return False
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self._elemental)
|
||||
|
||||
|
||||
class AuxiliaryObjective(Objective):
|
||||
"""An additional objective that can be optimized after objectives."""
|
||||
|
||||
__slots__ = ("_id",)
|
||||
|
||||
def __init__(self, elem: elemental.Elemental, obj_id: int) -> None:
|
||||
"""Internal only, prefer Model functions (add_auxiliary_objective() and get_auxiliary_objective())."""
|
||||
super().__init__(elem)
|
||||
if not isinstance(obj_id, int):
|
||||
raise TypeError(
|
||||
f"obj_id type should be int, was: {type(obj_id).__name__!r}"
|
||||
)
|
||||
self._id: int = obj_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._elemental.get_element_name(
|
||||
enums.ElementType.AUXILIARY_OBJECTIVE, self._id
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
"""Returns the id of this objective."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def is_maximize(self) -> bool:
|
||||
return self._elemental.get_attr(
|
||||
enums.BoolAttr1.AUXILIARY_OBJECTIVE_MAXIMIZE, (self._id,)
|
||||
)
|
||||
|
||||
@is_maximize.setter
|
||||
def is_maximize(self, is_maximize: bool) -> None:
|
||||
self._elemental.set_attr(
|
||||
enums.BoolAttr1.AUXILIARY_OBJECTIVE_MAXIMIZE,
|
||||
(self._id,),
|
||||
is_maximize,
|
||||
)
|
||||
|
||||
@property
|
||||
def offset(self) -> float:
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr1.AUXILIARY_OBJECTIVE_OFFSET, (self._id,)
|
||||
)
|
||||
|
||||
@offset.setter
|
||||
def offset(self, value: float) -> None:
|
||||
self._elemental.set_attr(
|
||||
enums.DoubleAttr1.AUXILIARY_OBJECTIVE_OFFSET,
|
||||
(self._id,),
|
||||
value,
|
||||
)
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return self._elemental.get_attr(
|
||||
enums.IntAttr1.AUXILIARY_OBJECTIVE_PRIORITY, (self._id,)
|
||||
)
|
||||
|
||||
@priority.setter
|
||||
def priority(self, value: int) -> None:
|
||||
self._elemental.set_attr(
|
||||
enums.IntAttr1.AUXILIARY_OBJECTIVE_PRIORITY,
|
||||
(self._id,),
|
||||
value,
|
||||
)
|
||||
|
||||
def set_linear_coefficient(self, var: variables.Variable, coef: float) -> None:
|
||||
from_model.model_is_same(self, var)
|
||||
self._elemental.set_attr(
|
||||
enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT,
|
||||
(self._id, var.id),
|
||||
coef,
|
||||
)
|
||||
|
||||
def get_linear_coefficient(self, var: variables.Variable) -> float:
|
||||
from_model.model_is_same(self, var)
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT,
|
||||
(
|
||||
self._id,
|
||||
var.id,
|
||||
),
|
||||
)
|
||||
|
||||
def linear_terms(self) -> Iterator[variables.LinearTerm]:
|
||||
keys = self._elemental.slice_attr(
|
||||
enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT,
|
||||
0,
|
||||
self._id,
|
||||
)
|
||||
var_index = 1
|
||||
coefs = self._elemental.get_attrs(
|
||||
enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT, keys
|
||||
)
|
||||
for i in range(len(keys)):
|
||||
yield variables.LinearTerm(
|
||||
variable=variables.Variable(self._elemental, int(keys[i, var_index])),
|
||||
coefficient=float(coefs[i]),
|
||||
)
|
||||
|
||||
def set_quadratic_coefficient(
|
||||
self,
|
||||
first_variable: variables.Variable,
|
||||
second_variable: variables.Variable,
|
||||
coef: float,
|
||||
) -> None:
|
||||
raise ValueError("Quadratic auxiliary objectives are not supported.")
|
||||
|
||||
def get_quadratic_coefficient(
|
||||
self,
|
||||
first_variable: variables.Variable,
|
||||
second_variable: variables.Variable,
|
||||
) -> float:
|
||||
from_model.model_is_same(self, first_variable)
|
||||
from_model.model_is_same(self, second_variable)
|
||||
if not self._elemental.element_exists(
|
||||
enums.ElementType.VARIABLE, first_variable.id
|
||||
):
|
||||
raise ValueError(f"Variable {first_variable} does not exist")
|
||||
if not self._elemental.element_exists(
|
||||
enums.ElementType.VARIABLE, second_variable.id
|
||||
):
|
||||
raise ValueError(f"Variable {second_variable} does not exist")
|
||||
return 0.0
|
||||
|
||||
def quadratic_terms(self) -> Iterator[variables.QuadraticTerm]:
|
||||
return iter(())
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clears objective coefficients and offset. Does not change direction."""
|
||||
self._elemental.clear_attr(enums.DoubleAttr1.AUXILIARY_OBJECTIVE_OFFSET)
|
||||
self._elemental.clear_attr(
|
||||
enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT
|
||||
)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if isinstance(other, AuxiliaryObjective):
|
||||
return self._elemental is other._elemental and self._id == other._id
|
||||
return False
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self._elemental, self._id))
|
||||
544
ortools/math_opt/python/objectives_test.py
Normal file
544
ortools/math_opt/python/objectives_test.py
Normal file
@@ -0,0 +1,544 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2010-2025 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 typing import Dict, Iterable, Tuple
|
||||
import unittest
|
||||
|
||||
from absl.testing import absltest
|
||||
from absl.testing import parameterized
|
||||
from ortools.math_opt.elemental.python import cpp_elemental
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import objectives
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
|
||||
def _model_and_objective(
|
||||
primary: bool, obj_name=""
|
||||
) -> Tuple[model.Model, objectives.Objective]:
|
||||
mod = model.Model(primary_objective_name=(obj_name if primary else ""))
|
||||
obj = (
|
||||
mod.objective
|
||||
if primary
|
||||
else mod.add_auxiliary_objective(priority=0, name=obj_name)
|
||||
)
|
||||
return (mod, obj)
|
||||
|
||||
|
||||
def _assert_linear_terms_equal_dict(
|
||||
test: unittest.TestCase,
|
||||
actual: Iterable[variables.LinearTerm],
|
||||
expected: Dict[variables.Variable, float],
|
||||
) -> None:
|
||||
actual = list(actual)
|
||||
actual_dict = {term.variable: term.coefficient for term in actual}
|
||||
test.assertDictEqual(actual_dict, expected)
|
||||
test.assertEqual(len(actual), len(actual_dict))
|
||||
|
||||
|
||||
def _assert_quadratic_terms_equal_dict(
|
||||
test: unittest.TestCase,
|
||||
actual: Iterable[variables.QuadraticTerm],
|
||||
expected: Dict[Tuple[variables.Variable, variables.Variable], float],
|
||||
) -> None:
|
||||
actual = list(actual)
|
||||
actual_dict = {
|
||||
(term.key.first_var, term.key.second_var): term.coefficient for term in actual
|
||||
}
|
||||
test.assertDictEqual(actual_dict, expected)
|
||||
test.assertEqual(len(actual), len(actual_dict))
|
||||
|
||||
|
||||
@parameterized.named_parameters(("primary", True), ("auxiliary", False))
|
||||
class LinearObjectiveTest(parameterized.TestCase):
|
||||
"""Tests that primary and auxiliary objectives handle linear terms."""
|
||||
|
||||
def test_same_model(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
mod.check_compatible(obj)
|
||||
|
||||
def test_name(self, primary: bool) -> None:
|
||||
_, obj = _model_and_objective(primary, "my_obj")
|
||||
self.assertEqual(obj.name, "my_obj")
|
||||
|
||||
def test_maximize(self, primary: bool) -> None:
|
||||
_, obj = _model_and_objective(primary)
|
||||
self.assertFalse(obj.is_maximize)
|
||||
obj.is_maximize = True
|
||||
self.assertTrue(obj.is_maximize)
|
||||
|
||||
def test_offset(self, primary: bool) -> None:
|
||||
_, obj = _model_and_objective(primary)
|
||||
self.assertEqual(obj.offset, 0.0)
|
||||
obj.offset = 3.2
|
||||
self.assertEqual(obj.offset, 3.2)
|
||||
|
||||
def test_priority(self, primary: bool) -> None:
|
||||
_, obj = _model_and_objective(primary)
|
||||
self.assertEqual(obj.priority, 0)
|
||||
obj.priority = 12
|
||||
self.assertEqual(obj.priority, 12)
|
||||
|
||||
def test_linear_coefficients_basic(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
z = mod.add_variable()
|
||||
|
||||
self.assertEqual(obj.get_linear_coefficient(x), 0.0)
|
||||
self.assertEqual(obj.get_linear_coefficient(y), 0.0)
|
||||
self.assertEqual(obj.get_linear_coefficient(z), 0.0)
|
||||
self.assertEmpty(list(obj.linear_terms()))
|
||||
|
||||
obj.set_linear_coefficient(x, 2.1)
|
||||
obj.set_linear_coefficient(z, 3.4)
|
||||
|
||||
self.assertEqual(obj.get_linear_coefficient(x), 2.1)
|
||||
self.assertEqual(obj.get_linear_coefficient(y), 0.0)
|
||||
self.assertEqual(obj.get_linear_coefficient(z), 3.4)
|
||||
_assert_linear_terms_equal_dict(self, obj.linear_terms(), {x: 2.1, z: 3.4})
|
||||
|
||||
def test_linear_coefficients_restore_to_zero(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
obj.set_linear_coefficient(x, 2.1)
|
||||
self.assertEqual(obj.get_linear_coefficient(x), 2.1)
|
||||
|
||||
obj.set_linear_coefficient(x, 0.0)
|
||||
|
||||
self.assertEqual(obj.get_linear_coefficient(x), 0.0)
|
||||
self.assertEmpty(list(obj.linear_terms()))
|
||||
|
||||
def test_clear(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
obj.set_linear_coefficient(x, 2.1)
|
||||
obj.is_maximize = True
|
||||
obj.offset = 4.0
|
||||
|
||||
obj.clear()
|
||||
|
||||
self.assertTrue(obj.is_maximize)
|
||||
self.assertEqual(obj.offset, 0.0)
|
||||
self.assertEmpty(list(obj.linear_terms()))
|
||||
|
||||
def test_as_linear_expression(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
obj.set_linear_coefficient(x, 2.1)
|
||||
obj.offset = 4.0
|
||||
|
||||
expr = obj.as_linear_expression()
|
||||
|
||||
self.assertEqual(expr.offset, 4.0)
|
||||
self.assertDictEqual(dict(expr.terms), {x: 2.1})
|
||||
|
||||
def test_add_linear(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
obj.set_linear_coefficient(x, 2.0)
|
||||
obj.offset = 5.5
|
||||
|
||||
obj.add_linear(1.0 + x + 4.0 * y)
|
||||
|
||||
self.assertAlmostEqual(obj.offset, 6.5, delta=1e-10)
|
||||
_assert_linear_terms_equal_dict(self, obj.linear_terms(), {x: 3.0, y: 4.0})
|
||||
|
||||
def test_add(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
obj.set_linear_coefficient(x, 2.0)
|
||||
obj.offset = 5.5
|
||||
|
||||
obj.add(1.0 + x)
|
||||
|
||||
self.assertAlmostEqual(obj.offset, 6.5, delta=1e-10)
|
||||
_assert_linear_terms_equal_dict(self, obj.linear_terms(), {x: 3.0})
|
||||
|
||||
def test_add_linear_rejects_quadratic(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
with self.assertRaisesRegex(TypeError, "Quadratic"):
|
||||
obj.add_linear(x * x) # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_set_to_linear(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
obj.set_linear_coefficient(x, 2.0)
|
||||
obj.offset = 5.5
|
||||
|
||||
obj.set_to_linear_expression(1.0 + x + 4.0 * y)
|
||||
|
||||
self.assertEqual(obj.offset, 1.0)
|
||||
_assert_linear_terms_equal_dict(self, obj.linear_terms(), {x: 1.0, y: 4.0})
|
||||
|
||||
def test_set_to_linear_rejects_quadratic(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
with self.assertRaisesRegex(TypeError, "Quadratic"):
|
||||
obj.set_to_linear_expression(x * x) # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_set_to_expression(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
obj.set_linear_coefficient(x, 2.0)
|
||||
obj.offset = 5.5
|
||||
|
||||
obj.set_to_expression(1.0 + x)
|
||||
|
||||
self.assertEqual(obj.offset, 1.0)
|
||||
_assert_linear_terms_equal_dict(self, obj.linear_terms(), {x: 1.0})
|
||||
|
||||
def test_get_linear_coef_of_deleted_variable(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
|
||||
mod.delete_variable(x)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
obj.get_linear_coefficient(x)
|
||||
|
||||
def test_set_linear_coef_of_deleted_variable(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
|
||||
mod.delete_variable(x)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
obj.set_linear_coefficient(x, 2.0)
|
||||
|
||||
def test_get_quadratic_coef_of_deleted_variable(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
|
||||
mod.delete_variable(x)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
obj.get_quadratic_coefficient(x, x)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
obj.get_quadratic_coefficient(x, y)
|
||||
|
||||
def test_delete_variable_terms_removed(self, primary: bool) -> None:
|
||||
mod, obj = _model_and_objective(primary)
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
obj.set_to_expression(x + y + 3.0)
|
||||
|
||||
mod.delete_variable(x)
|
||||
|
||||
self.assertEqual(obj.offset, 3.0)
|
||||
_assert_linear_terms_equal_dict(self, obj.linear_terms(), {y: 1.0})
|
||||
|
||||
def test_objective_wrong_model_linear(self, primary: bool) -> None:
|
||||
mod1, _ = _model_and_objective(primary)
|
||||
mod2, obj2 = _model_and_objective(primary)
|
||||
x1 = mod1.add_variable()
|
||||
mod2.add_variable()
|
||||
with self.assertRaises(ValueError):
|
||||
obj2.set_linear_coefficient(x1, 1.0)
|
||||
with self.assertRaises(ValueError):
|
||||
obj2.get_linear_coefficient(x1)
|
||||
|
||||
def test_objective_wrong_model_get_quadratic(self, primary: bool) -> None:
|
||||
mod1, _ = _model_and_objective(primary)
|
||||
mod2, obj2 = _model_and_objective(primary)
|
||||
x = mod1.add_variable()
|
||||
other_x = mod2.add_variable(name="x")
|
||||
with self.assertRaises(ValueError):
|
||||
obj2.get_quadratic_coefficient(x, other_x)
|
||||
with self.assertRaises(ValueError):
|
||||
obj2.get_quadratic_coefficient(other_x, x)
|
||||
|
||||
|
||||
class PrimaryObjectiveTest(absltest.TestCase):
|
||||
|
||||
def test_eq(self) -> None:
|
||||
mod1 = model.Model()
|
||||
mod2 = model.Model()
|
||||
|
||||
self.assertEqual(mod1.objective, mod1.objective)
|
||||
self.assertNotEqual(mod1.objective, mod2.objective)
|
||||
|
||||
def test_quadratic_coefficients_basic(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.objective
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
z = mod.add_variable()
|
||||
|
||||
self.assertEqual(obj.get_quadratic_coefficient(x, x), 0.0)
|
||||
self.assertEqual(obj.get_quadratic_coefficient(y, z), 0.0)
|
||||
self.assertEqual(obj.get_quadratic_coefficient(z, x), 0.0)
|
||||
self.assertEmpty(list(obj.quadratic_terms()))
|
||||
|
||||
obj.set_quadratic_coefficient(x, x, 2.1)
|
||||
obj.set_quadratic_coefficient(y, z, 3.1)
|
||||
obj.set_quadratic_coefficient(z, x, 4.1)
|
||||
|
||||
self.assertEqual(obj.get_quadratic_coefficient(x, x), 2.1)
|
||||
self.assertEqual(obj.get_quadratic_coefficient(y, z), 3.1)
|
||||
self.assertEqual(obj.get_quadratic_coefficient(z, y), 3.1)
|
||||
self.assertEqual(obj.get_quadratic_coefficient(z, x), 4.1)
|
||||
self.assertEqual(obj.get_quadratic_coefficient(x, z), 4.1)
|
||||
self.assertEqual(obj.get_quadratic_coefficient(y, y), 0.0)
|
||||
_assert_quadratic_terms_equal_dict(
|
||||
self, obj.quadratic_terms(), {(x, x): 2.1, (y, z): 3.1, (x, z): 4.1}
|
||||
)
|
||||
|
||||
def test_quadratic_coefficients_restore_to_zero(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.objective
|
||||
x = mod.add_variable()
|
||||
obj.set_quadratic_coefficient(x, x, 2.1)
|
||||
self.assertEqual(obj.get_quadratic_coefficient(x, x), 2.1)
|
||||
|
||||
obj.set_quadratic_coefficient(x, x, 0.0)
|
||||
|
||||
self.assertEqual(obj.get_quadratic_coefficient(x, x), 0.0)
|
||||
self.assertEmpty(list(obj.quadratic_terms()))
|
||||
|
||||
def test_clear(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.objective
|
||||
x = mod.add_variable()
|
||||
obj.set_quadratic_coefficient(x, x, 2.1)
|
||||
|
||||
obj.clear()
|
||||
|
||||
self.assertEqual(obj.get_quadratic_coefficient(x, x), 0.0)
|
||||
self.assertEmpty(list(obj.quadratic_terms()))
|
||||
|
||||
def test_as_linear_expression_fails(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.objective
|
||||
x = mod.add_variable()
|
||||
obj.set_quadratic_coefficient(x, x, 2.1)
|
||||
|
||||
with self.assertRaisesRegex(TypeError, "quadratic"):
|
||||
obj.as_linear_expression()
|
||||
|
||||
def test_as_quadratic_expression(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.objective
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
obj.offset = 2.1
|
||||
obj.set_linear_coefficient(y, 3.1)
|
||||
obj.set_quadratic_coefficient(x, x, 4.1)
|
||||
obj.set_quadratic_coefficient(x, y, 5.1)
|
||||
|
||||
quad_expr = obj.as_quadratic_expression()
|
||||
|
||||
self.assertEqual(quad_expr.offset, 2.1)
|
||||
self.assertDictEqual(dict(quad_expr.linear_terms), {y: 3.1})
|
||||
self.assertDictEqual(
|
||||
dict(quad_expr.quadratic_terms),
|
||||
{
|
||||
variables.QuadraticTermKey(x, x): 4.1,
|
||||
variables.QuadraticTermKey(x, y): 5.1,
|
||||
},
|
||||
)
|
||||
|
||||
def test_add_quadratic(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.objective
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
obj.offset = -2.0
|
||||
obj.set_linear_coefficient(y, 3.0)
|
||||
obj.set_quadratic_coefficient(x, x, 4.0)
|
||||
|
||||
obj.add_quadratic(1.0 + x + 2 * y + x * x + 3.0 * x * y)
|
||||
|
||||
self.assertEqual(obj.offset, -1.0)
|
||||
_assert_linear_terms_equal_dict(self, obj.linear_terms(), {x: 1.0, y: 5.0})
|
||||
_assert_quadratic_terms_equal_dict(
|
||||
self, obj.quadratic_terms(), {(x, x): 5.0, (x, y): 3.0}
|
||||
)
|
||||
|
||||
def test_add(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.objective
|
||||
x = mod.add_variable()
|
||||
obj.set_linear_coefficient(x, 2.0)
|
||||
|
||||
obj.add(x * x)
|
||||
|
||||
self.assertEqual(obj.offset, 0.0)
|
||||
_assert_linear_terms_equal_dict(self, obj.linear_terms(), {x: 2.0})
|
||||
_assert_quadratic_terms_equal_dict(self, obj.quadratic_terms(), {(x, x): 1.0})
|
||||
|
||||
def test_set_to_quadratic_expression(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.objective
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
obj.offset = -2.0
|
||||
obj.set_linear_coefficient(y, 3.0)
|
||||
obj.set_quadratic_coefficient(x, x, 4.0)
|
||||
|
||||
obj.set_to_quadratic_expression(1.0 + x + 2 * y + x * x + 3.0 * x * y)
|
||||
|
||||
self.assertEqual(obj.offset, 1.0)
|
||||
_assert_linear_terms_equal_dict(self, obj.linear_terms(), {x: 1.0, y: 2.0})
|
||||
_assert_quadratic_terms_equal_dict(
|
||||
self, obj.quadratic_terms(), {(x, x): 1.0, (x, y): 3.0}
|
||||
)
|
||||
|
||||
def test_set_to_expression(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.objective
|
||||
x = mod.add_variable()
|
||||
obj.set_linear_coefficient(x, 2.0)
|
||||
|
||||
obj.set_to_expression(x * x)
|
||||
|
||||
self.assertEqual(obj.offset, 0.0)
|
||||
self.assertEmpty(list(obj.linear_terms()))
|
||||
_assert_quadratic_terms_equal_dict(self, obj.quadratic_terms(), {(x, x): 1.0})
|
||||
|
||||
def test_set_quadratic_coef_of_deleted_variable(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
|
||||
mod.delete_variable(x)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
mod.objective.set_quadratic_coefficient(x, x, 1.0)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
mod.objective.set_quadratic_coefficient(x, y, 1.0)
|
||||
|
||||
def test_delete_variable_quad_terms_removed(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.objective
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
obj.set_to_expression(x * x + x * y + y * y)
|
||||
|
||||
mod.delete_variable(x)
|
||||
|
||||
_assert_quadratic_terms_equal_dict(
|
||||
self, mod.objective.quadratic_terms(), {(y, y): 1.0}
|
||||
)
|
||||
|
||||
def test_objective_wrong_model_set_quadratic(self) -> None:
|
||||
mod1 = model.Model()
|
||||
x = mod1.add_variable()
|
||||
mod2 = model.Model()
|
||||
other_x = mod2.add_variable(name="x")
|
||||
with self.assertRaises(ValueError):
|
||||
mod2.objective.set_quadratic_coefficient(x, other_x, 1.0)
|
||||
with self.assertRaises(ValueError):
|
||||
mod2.objective.set_quadratic_coefficient(other_x, x, 1.0)
|
||||
|
||||
|
||||
class AuxiliaryObjectiveTest(absltest.TestCase):
|
||||
|
||||
def test_invalid_id_type(self) -> None:
|
||||
elemental = cpp_elemental.CppElemental()
|
||||
with self.assertRaisesRegex(TypeError, "obj_id type"):
|
||||
objectives.AuxiliaryObjective(
|
||||
elemental, "dog"
|
||||
) # pytype: disable=wrong-arg-types
|
||||
|
||||
def test_eq(self) -> None:
|
||||
mod1 = model.Model()
|
||||
aux1 = mod1.add_auxiliary_objective(priority=1)
|
||||
aux2 = mod1.add_auxiliary_objective(priority=2)
|
||||
mod2 = model.Model()
|
||||
aux3 = mod2.add_auxiliary_objective(priority=1)
|
||||
|
||||
self.assertEqual(aux1, aux1)
|
||||
self.assertEqual(aux1, mod1.get_auxiliary_objective(0))
|
||||
self.assertNotEqual(aux1, aux2)
|
||||
self.assertNotEqual(aux1, aux3)
|
||||
self.assertNotEqual(aux1, mod1.objective)
|
||||
|
||||
def test_id(self) -> None:
|
||||
mod = model.Model()
|
||||
aux1 = mod.add_auxiliary_objective(priority=1)
|
||||
aux2 = mod.add_auxiliary_objective(priority=2)
|
||||
|
||||
self.assertEqual(aux1.id, 0)
|
||||
self.assertEqual(aux2.id, 1)
|
||||
|
||||
def test_get_quadratic_coefficients_is_zero(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.add_auxiliary_objective(priority=1)
|
||||
x = mod.add_variable()
|
||||
|
||||
self.assertEqual(obj.get_quadratic_coefficient(x, x), 0.0)
|
||||
self.assertEmpty(list(obj.quadratic_terms()))
|
||||
|
||||
def test_set_quadratic_coefficients_is_error(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.add_auxiliary_objective(priority=1)
|
||||
x = mod.add_variable()
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Quadratic"):
|
||||
obj.set_quadratic_coefficient(x, x, 2.1)
|
||||
|
||||
def test_as_quadratic_expression_with_linear_no_crash(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.add_auxiliary_objective(priority=1)
|
||||
y = mod.add_variable()
|
||||
obj.offset = 2.1
|
||||
obj.set_linear_coefficient(y, 3.1)
|
||||
|
||||
quad_expr = obj.as_quadratic_expression()
|
||||
|
||||
self.assertEqual(quad_expr.offset, 2.1)
|
||||
self.assertDictEqual(dict(quad_expr.linear_terms), {y: 3.1})
|
||||
self.assertEmpty(list(quad_expr.quadratic_terms))
|
||||
|
||||
def test_add_quadratic_errors(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.add_auxiliary_objective(priority=1)
|
||||
x = mod.add_variable()
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Quadratic"):
|
||||
obj.add_quadratic(x * x)
|
||||
|
||||
def test_add_is_error_if_quad(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.add_auxiliary_objective(priority=1)
|
||||
x = mod.add_variable()
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Quadratic"):
|
||||
obj.add(x * x)
|
||||
|
||||
def test_set_to_quadratic_expression_error(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.add_auxiliary_objective(priority=1)
|
||||
x = mod.add_variable()
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Quadratic"):
|
||||
obj.set_to_quadratic_expression(x * x)
|
||||
|
||||
def test_set_to_expression_error_when_quadratic(self) -> None:
|
||||
mod = model.Model()
|
||||
obj = mod.add_auxiliary_objective(priority=1)
|
||||
x = mod.add_variable()
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Quadratic"):
|
||||
obj.set_to_expression(x * x)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
177
ortools/math_opt/python/quadratic_constraints.py
Normal file
177
ortools/math_opt/python/quadratic_constraints.py
Normal file
@@ -0,0 +1,177 @@
|
||||
# Copyright 2010-2025 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.
|
||||
|
||||
"""Quadratic constraint in a model."""
|
||||
|
||||
from typing import Any, Iterator
|
||||
|
||||
from ortools.math_opt.elemental.python import enums
|
||||
from ortools.math_opt.python import from_model
|
||||
from ortools.math_opt.python import variables
|
||||
from ortools.math_opt.python.elemental import elemental
|
||||
|
||||
|
||||
class QuadraticConstraint(from_model.FromModel):
|
||||
"""A quadratic constraint for an optimization model.
|
||||
|
||||
A QuadraticConstraint adds the following restriction on feasible solutions to
|
||||
an optimization model:
|
||||
lb <= sum_i a_i x_i + sum_i sum_{j : i <= j} b_ij x_i x_j <= ub
|
||||
where x_i are the decision variables of the problem. lb == ub is allowed, and
|
||||
this models an equality constraint. lb > ub is also allowed, but the
|
||||
optimization problem will be infeasible.
|
||||
|
||||
Quadratic constraints have limited mutability. You can delete a variable
|
||||
that the constraint uses, or you can delete the entire constraint. You
|
||||
currently cannot update bounds or coefficients. This may change in future
|
||||
versions.
|
||||
|
||||
A QuadraticConstraint can be queried as follows:
|
||||
* lower_bound: a float property, lb above. Should not be NaN nor +inf.
|
||||
* upper_bound: a float property, ub above. Should not be NaN nor -inf.
|
||||
* get_linear_coefficient(): get the a_i * x_i terms. The variable must be
|
||||
from the same model as this constraint, and the a_i must be finite and not
|
||||
NaN. The coefficient for any variable not set is 0.0.
|
||||
* get_quadratic_coefficient(): like get_linear_coefficient() but for the
|
||||
b_ij terms. Note that get_quadratic_coefficient(x, y, 8) and
|
||||
get_quadratic_coefficient(y, x, 8) have the same result.
|
||||
|
||||
The name is optional, read only, and used only for debugging. Non-empty names
|
||||
should be distinct.
|
||||
|
||||
Do not create a QuadraticConstraint directly, use
|
||||
Model.add_quadratic_constraint() instead. Two QuadraticConstraint objects
|
||||
can represent the same constraint (for the same model). They will have the
|
||||
same underlying QuadraticConstraint.elemental for storing the data. The
|
||||
QuadraticConstraint class is simply a reference to an Elemental.
|
||||
"""
|
||||
|
||||
__slots__ = "_elemental", "_id"
|
||||
|
||||
def __init__(self, elem: elemental.Elemental, cid: int) -> None:
|
||||
"""Internal only, prefer Model functions (add_quadratic_constraint() and get_quadratic_constraint())."""
|
||||
if not isinstance(cid, int):
|
||||
raise TypeError(f"cid type should be int, was:{type(cid).__name__!r}")
|
||||
self._elemental: elemental.Elemental = elem
|
||||
self._id: int = cid
|
||||
|
||||
@property
|
||||
def lower_bound(self) -> float:
|
||||
"""The quadratic expression of the constraint must be at least this."""
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr1.QUADRATIC_CONSTRAINT_LOWER_BOUND, (self._id,)
|
||||
)
|
||||
|
||||
@property
|
||||
def upper_bound(self) -> float:
|
||||
"""The quadratic expression of the constraint must be at most this."""
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr1.QUADRATIC_CONSTRAINT_UPPER_BOUND, (self._id,)
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""The name of this constraint."""
|
||||
return self._elemental.get_element_name(
|
||||
enums.ElementType.QUADRATIC_CONSTRAINT, self._id
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
"""A unique (for the model) identifier for this constraint."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def elemental(self) -> elemental.Elemental:
|
||||
"""Internal use only."""
|
||||
return self._elemental
|
||||
|
||||
def get_linear_coefficient(self, var: variables.Variable) -> float:
|
||||
"""Returns the linear coefficient for var in the constraint's quadratic expression."""
|
||||
from_model.model_is_same(var, self)
|
||||
return self._elemental.get_attr(
|
||||
enums.DoubleAttr2.QUADRATIC_CONSTRAINT_LINEAR_COEFFICIENT,
|
||||
(self._id, var.id),
|
||||
)
|
||||
|
||||
def linear_terms(self) -> Iterator[variables.LinearTerm]:
|
||||
"""Yields variable/coefficient pairs from the linear part of the constraint.
|
||||
|
||||
Only the pairs with nonzero coefficient are returned.
|
||||
|
||||
Yields:
|
||||
The variable, coefficient pairs.
|
||||
"""
|
||||
keys = self._elemental.slice_attr(
|
||||
enums.DoubleAttr2.QUADRATIC_CONSTRAINT_LINEAR_COEFFICIENT, 0, self._id
|
||||
)
|
||||
coefs = self._elemental.get_attrs(
|
||||
enums.DoubleAttr2.QUADRATIC_CONSTRAINT_LINEAR_COEFFICIENT, keys
|
||||
)
|
||||
for i in range(len(keys)):
|
||||
yield variables.LinearTerm(
|
||||
variable=variables.Variable(self._elemental, int(keys[i, 1])),
|
||||
coefficient=float(coefs[i]),
|
||||
)
|
||||
|
||||
def get_quadratic_coefficient(
|
||||
self, var1: variables.Variable, var2: variables.Variable
|
||||
) -> float:
|
||||
"""Returns the quadratic coefficient for the pair (var1, var2) in the constraint's quadratic expression."""
|
||||
from_model.model_is_same(var1, self)
|
||||
from_model.model_is_same(var2, self)
|
||||
return self._elemental.get_attr(
|
||||
enums.SymmetricDoubleAttr3.QUADRATIC_CONSTRAINT_QUADRATIC_COEFFICIENT,
|
||||
(self._id, var1.id, var2.id),
|
||||
)
|
||||
|
||||
def quadratic_terms(self) -> Iterator[variables.QuadraticTerm]:
|
||||
"""Yields variable/coefficient pairs from the quadratic part of the constraint.
|
||||
|
||||
Only the pairs with nonzero coefficient are returned.
|
||||
|
||||
Yields:
|
||||
The variable, coefficient pairs.
|
||||
"""
|
||||
keys = self._elemental.slice_attr(
|
||||
enums.SymmetricDoubleAttr3.QUADRATIC_CONSTRAINT_QUADRATIC_COEFFICIENT,
|
||||
0,
|
||||
self._id,
|
||||
)
|
||||
coefs = self._elemental.get_attrs(
|
||||
enums.SymmetricDoubleAttr3.QUADRATIC_CONSTRAINT_QUADRATIC_COEFFICIENT,
|
||||
keys,
|
||||
)
|
||||
for i in range(len(keys)):
|
||||
yield variables.QuadraticTerm(
|
||||
variables.QuadraticTermKey(
|
||||
variables.Variable(self._elemental, int(keys[i, 1])),
|
||||
variables.Variable(self._elemental, int(keys[i, 2])),
|
||||
),
|
||||
coefficient=float(coefs[i]),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Returns the name, or a string containing the id if the name is empty."""
|
||||
return self.name if self.name else f"quadratic_constraint_{self.id}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<QuadraticConstraint id: {self.id}, name: {self.name!r}>"
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if isinstance(other, QuadraticConstraint):
|
||||
return self._id == other._id and self._elemental is other._elemental
|
||||
return False
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self._id, self._elemental))
|
||||
95
ortools/math_opt/python/quadratic_constraints_test.py
Normal file
95
ortools/math_opt/python/quadratic_constraints_test.py
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2010-2025 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 absl.testing import absltest
|
||||
from ortools.math_opt.python import model
|
||||
|
||||
|
||||
class QuadraticConstraintsTest(absltest.TestCase):
|
||||
|
||||
def test_empty_constraint(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
quad_con = mod.add_quadratic_constraint()
|
||||
self.assertEqual(quad_con.lower_bound, -math.inf)
|
||||
self.assertEqual(quad_con.upper_bound, math.inf)
|
||||
self.assertEqual(quad_con.id, 0)
|
||||
self.assertEqual(quad_con.name, "")
|
||||
self.assertEqual(quad_con.get_linear_coefficient(x), 0.0)
|
||||
self.assertEqual(quad_con.get_quadratic_coefficient(x, x), 0.0)
|
||||
self.assertEmpty(list(quad_con.linear_terms()))
|
||||
self.assertEmpty(list(quad_con.quadratic_terms()))
|
||||
|
||||
def test_full_constraint(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
y = mod.add_variable()
|
||||
quad_con = mod.add_quadratic_constraint(
|
||||
lb=3.0, ub=4.0, expr=5 * x + 6 * y + 7 * x * y + 8 * y * y, name="c"
|
||||
)
|
||||
self.assertEqual(quad_con.lower_bound, 3.0)
|
||||
self.assertEqual(quad_con.upper_bound, 4.0)
|
||||
self.assertEqual(quad_con.id, 0)
|
||||
self.assertEqual(quad_con.name, "c")
|
||||
self.assertEqual(quad_con.get_linear_coefficient(x), 5.0)
|
||||
self.assertEqual(quad_con.get_linear_coefficient(y), 6.0)
|
||||
self.assertEqual(quad_con.get_quadratic_coefficient(x, y), 7.0)
|
||||
self.assertEqual(quad_con.get_quadratic_coefficient(y, y), 8.0)
|
||||
self.assertDictEqual(
|
||||
{term.variable: term.coefficient for term in quad_con.linear_terms()},
|
||||
{x: 5.0, y: 6.0},
|
||||
)
|
||||
self.assertDictEqual(
|
||||
{
|
||||
(term.key.first_var, term.key.second_var): term.coefficient
|
||||
for term in quad_con.quadratic_terms()
|
||||
},
|
||||
{(x, y): 7.0, (y, y): 8.0},
|
||||
)
|
||||
|
||||
def test_eq(self) -> None:
|
||||
mod1 = model.Model()
|
||||
mod2 = model.Model()
|
||||
q1 = mod1.add_quadratic_constraint()
|
||||
q2 = mod1.add_quadratic_constraint()
|
||||
q3 = mod2.add_quadratic_constraint()
|
||||
q1_other = mod1.get_quadratic_constraint(0)
|
||||
self.assertEqual(q1, q1_other)
|
||||
self.assertNotEqual(q1, q2)
|
||||
self.assertNotEqual(q1, q3)
|
||||
self.assertNotEqual(q1, "cat")
|
||||
|
||||
def test_str(self) -> None:
|
||||
mod = model.Model()
|
||||
quad_con = mod.add_quadratic_constraint(name="qqq")
|
||||
self.assertEqual(str(quad_con), "qqq")
|
||||
self.assertEqual(repr(quad_con), "<QuadraticConstraint id: 0, name: 'qqq'>")
|
||||
|
||||
def test_get_coefficient_variable_wrong_model(self) -> None:
|
||||
mod1 = model.Model()
|
||||
mod2 = model.Model()
|
||||
q1 = mod1.add_quadratic_constraint()
|
||||
# Ensure the bad model, not the bad id, causes the error.
|
||||
mod1.add_variable()
|
||||
x2 = mod2.add_variable()
|
||||
with self.assertRaises(ValueError):
|
||||
q1.get_linear_coefficient(x2)
|
||||
with self.assertRaises(ValueError):
|
||||
q1.get_quadratic_coefficient(x2, x2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
@@ -19,8 +19,10 @@ from typing import Dict, Iterable, List, Optional, overload
|
||||
|
||||
from ortools.gscip import gscip_pb2
|
||||
from ortools.math_opt import result_pb2
|
||||
from ortools.math_opt.python import linear_constraints as linear_constraints_mod
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import solution
|
||||
from ortools.math_opt.python import variables as variables_mod
|
||||
from ortools.math_opt.solvers import osqp_pb2
|
||||
|
||||
_NO_DUAL_SOLUTION_ERROR = (
|
||||
@@ -458,9 +460,10 @@ class SolveResult:
|
||||
"""
|
||||
if not self.solutions:
|
||||
return False
|
||||
sol = self.solutions[0]
|
||||
return (
|
||||
self.solutions[0].primal_solution is not None
|
||||
and self.solutions[0].primal_solution.feasibility_status
|
||||
sol.primal_solution is not None
|
||||
and sol.primal_solution.feasibility_status
|
||||
== solution.SolutionStatus.FEASIBLE
|
||||
)
|
||||
|
||||
@@ -483,8 +486,9 @@ class SolveResult:
|
||||
"""
|
||||
if not self.has_primal_feasible_solution():
|
||||
raise ValueError("No primal feasible solution available.")
|
||||
assert self.solutions[0].primal_solution is not None
|
||||
return self.solutions[0].primal_solution.objective_value
|
||||
sol = self.solutions[0]
|
||||
assert sol.primal_solution is not None
|
||||
return sol.primal_solution.objective_value
|
||||
|
||||
def best_objective_bound(self) -> float:
|
||||
"""Returns a bound on the best possible objective value.
|
||||
@@ -495,13 +499,17 @@ class SolveResult:
|
||||
return self.termination.objective_bounds.dual_bound
|
||||
|
||||
@overload
|
||||
def variable_values(self, variables: None = ...) -> Dict[model.Variable, float]: ...
|
||||
def variable_values(
|
||||
self, variables: None = ...
|
||||
) -> Dict[variables_mod.Variable, float]: ...
|
||||
|
||||
@overload
|
||||
def variable_values(self, variables: model.Variable) -> float: ...
|
||||
def variable_values(self, variables: variables_mod.Variable) -> float: ...
|
||||
|
||||
@overload
|
||||
def variable_values(self, variables: Iterable[model.Variable]) -> List[float]: ...
|
||||
def variable_values(
|
||||
self, variables: Iterable[variables_mod.Variable]
|
||||
) -> List[float]: ...
|
||||
|
||||
def variable_values(self, variables=None):
|
||||
"""The variable values from the best primal feasible solution.
|
||||
@@ -524,15 +532,14 @@ class SolveResult:
|
||||
"""
|
||||
if not self.has_primal_feasible_solution():
|
||||
raise ValueError("No primal feasible solution available.")
|
||||
assert self.solutions[0].primal_solution is not None
|
||||
sol = self.solutions[0]
|
||||
assert sol.primal_solution is not None
|
||||
if variables is None:
|
||||
return self.solutions[0].primal_solution.variable_values
|
||||
if isinstance(variables, model.Variable):
|
||||
return self.solutions[0].primal_solution.variable_values[variables]
|
||||
return sol.primal_solution.variable_values
|
||||
if isinstance(variables, variables_mod.Variable):
|
||||
return sol.primal_solution.variable_values[variables]
|
||||
if isinstance(variables, Iterable):
|
||||
return [
|
||||
self.solutions[0].primal_solution.variable_values[v] for v in variables
|
||||
]
|
||||
return [sol.primal_solution.variable_values[v] for v in variables]
|
||||
raise TypeError(
|
||||
"unsupported type in argument for "
|
||||
f"variable_values: {type(variables).__name__!r}"
|
||||
@@ -560,14 +567,14 @@ class SolveResult:
|
||||
@overload
|
||||
def ray_variable_values(
|
||||
self, variables: None = ...
|
||||
) -> Dict[model.Variable, float]: ...
|
||||
) -> Dict[variables_mod.Variable, float]: ...
|
||||
|
||||
@overload
|
||||
def ray_variable_values(self, variables: model.Variable) -> float: ...
|
||||
def ray_variable_values(self, variables: variables_mod.Variable) -> float: ...
|
||||
|
||||
@overload
|
||||
def ray_variable_values(
|
||||
self, variables: Iterable[model.Variable]
|
||||
self, variables: Iterable[variables_mod.Variable]
|
||||
) -> List[float]: ...
|
||||
|
||||
def ray_variable_values(self, variables=None):
|
||||
@@ -593,7 +600,7 @@ class SolveResult:
|
||||
raise ValueError("No primal ray available.")
|
||||
if variables is None:
|
||||
return self.primal_rays[0].variable_values
|
||||
if isinstance(variables, model.Variable):
|
||||
if isinstance(variables, variables_mod.Variable):
|
||||
return self.primal_rays[0].variable_values[variables]
|
||||
if isinstance(variables, Iterable):
|
||||
return [self.primal_rays[0].variable_values[v] for v in variables]
|
||||
@@ -614,23 +621,26 @@ class SolveResult:
|
||||
"""
|
||||
if not self.solutions:
|
||||
return False
|
||||
sol = self.solutions[0]
|
||||
return (
|
||||
self.solutions[0].dual_solution is not None
|
||||
and self.solutions[0].dual_solution.feasibility_status
|
||||
== solution.SolutionStatus.FEASIBLE
|
||||
sol.dual_solution is not None
|
||||
and sol.dual_solution.feasibility_status == solution.SolutionStatus.FEASIBLE
|
||||
)
|
||||
|
||||
@overload
|
||||
def dual_values(
|
||||
self, linear_constraints: None = ...
|
||||
) -> Dict[model.LinearConstraint, float]: ...
|
||||
|
||||
@overload
|
||||
def dual_values(self, linear_constraints: model.LinearConstraint) -> float: ...
|
||||
) -> Dict[linear_constraints_mod.LinearConstraint, float]: ...
|
||||
|
||||
@overload
|
||||
def dual_values(
|
||||
self, linear_constraints: Iterable[model.LinearConstraint]
|
||||
self, linear_constraints: linear_constraints_mod.LinearConstraint
|
||||
) -> float: ...
|
||||
|
||||
@overload
|
||||
def dual_values(
|
||||
self,
|
||||
linear_constraints: Iterable[linear_constraints_mod.LinearConstraint],
|
||||
) -> List[float]: ...
|
||||
|
||||
def dual_values(self, linear_constraints=None):
|
||||
@@ -661,29 +671,31 @@ class SolveResult:
|
||||
"""
|
||||
if not self.has_dual_feasible_solution():
|
||||
raise ValueError(_NO_DUAL_SOLUTION_ERROR)
|
||||
assert self.solutions[0].dual_solution is not None
|
||||
sol = self.solutions[0]
|
||||
assert sol.dual_solution is not None
|
||||
if linear_constraints is None:
|
||||
return self.solutions[0].dual_solution.dual_values
|
||||
if isinstance(linear_constraints, model.LinearConstraint):
|
||||
return self.solutions[0].dual_solution.dual_values[linear_constraints]
|
||||
return sol.dual_solution.dual_values
|
||||
if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint):
|
||||
return sol.dual_solution.dual_values[linear_constraints]
|
||||
if isinstance(linear_constraints, Iterable):
|
||||
return [
|
||||
self.solutions[0].dual_solution.dual_values[c]
|
||||
for c in linear_constraints
|
||||
]
|
||||
return [sol.dual_solution.dual_values[c] for c in linear_constraints]
|
||||
raise TypeError(
|
||||
"unsupported type in argument for "
|
||||
f"dual_values: {type(linear_constraints).__name__!r}"
|
||||
)
|
||||
|
||||
@overload
|
||||
def reduced_costs(self, variables: None = ...) -> Dict[model.Variable, float]: ...
|
||||
def reduced_costs(
|
||||
self, variables: None = ...
|
||||
) -> Dict[variables_mod.Variable, float]: ...
|
||||
|
||||
@overload
|
||||
def reduced_costs(self, variables: model.Variable) -> float: ...
|
||||
def reduced_costs(self, variables: variables_mod.Variable) -> float: ...
|
||||
|
||||
@overload
|
||||
def reduced_costs(self, variables: Iterable[model.Variable]) -> List[float]: ...
|
||||
def reduced_costs(
|
||||
self, variables: Iterable[variables_mod.Variable]
|
||||
) -> List[float]: ...
|
||||
|
||||
def reduced_costs(self, variables=None):
|
||||
"""The reduced costs associated to the best solution.
|
||||
@@ -710,13 +722,14 @@ class SolveResult:
|
||||
"""
|
||||
if not self.has_dual_feasible_solution():
|
||||
raise ValueError(_NO_DUAL_SOLUTION_ERROR)
|
||||
assert self.solutions[0].dual_solution is not None
|
||||
sol = self.solutions[0]
|
||||
assert sol.dual_solution is not None
|
||||
if variables is None:
|
||||
return self.solutions[0].dual_solution.reduced_costs
|
||||
if isinstance(variables, model.Variable):
|
||||
return self.solutions[0].dual_solution.reduced_costs[variables]
|
||||
return sol.dual_solution.reduced_costs
|
||||
if isinstance(variables, variables_mod.Variable):
|
||||
return sol.dual_solution.reduced_costs[variables]
|
||||
if isinstance(variables, Iterable):
|
||||
return [self.solutions[0].dual_solution.reduced_costs[v] for v in variables]
|
||||
return [sol.dual_solution.reduced_costs[v] for v in variables]
|
||||
raise TypeError(
|
||||
"unsupported type in argument for "
|
||||
f"reduced_costs: {type(variables).__name__!r}"
|
||||
@@ -736,14 +749,17 @@ class SolveResult:
|
||||
@overload
|
||||
def ray_dual_values(
|
||||
self, linear_constraints: None = ...
|
||||
) -> Dict[model.LinearConstraint, float]: ...
|
||||
|
||||
@overload
|
||||
def ray_dual_values(self, linear_constraints: model.LinearConstraint) -> float: ...
|
||||
) -> Dict[linear_constraints_mod.LinearConstraint, float]: ...
|
||||
|
||||
@overload
|
||||
def ray_dual_values(
|
||||
self, linear_constraints: Iterable[model.LinearConstraint]
|
||||
self, linear_constraints: linear_constraints_mod.LinearConstraint
|
||||
) -> float: ...
|
||||
|
||||
@overload
|
||||
def ray_dual_values(
|
||||
self,
|
||||
linear_constraints: Iterable[linear_constraints_mod.LinearConstraint],
|
||||
) -> List[float]: ...
|
||||
|
||||
def ray_dual_values(self, linear_constraints=None):
|
||||
@@ -770,12 +786,13 @@ class SolveResult:
|
||||
"""
|
||||
if not self.has_dual_ray():
|
||||
raise ValueError("No dual ray available.")
|
||||
ray = self.dual_rays[0]
|
||||
if linear_constraints is None:
|
||||
return self.dual_rays[0].dual_values
|
||||
if isinstance(linear_constraints, model.LinearConstraint):
|
||||
return self.dual_rays[0].dual_values[linear_constraints]
|
||||
return ray.dual_values
|
||||
if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint):
|
||||
return ray.dual_values[linear_constraints]
|
||||
if isinstance(linear_constraints, Iterable):
|
||||
return [self.dual_rays[0].dual_values[v] for v in linear_constraints]
|
||||
return [ray.dual_values[v] for v in linear_constraints]
|
||||
raise TypeError(
|
||||
"unsupported type in argument for "
|
||||
f"ray_dual_values: {type(linear_constraints).__name__!r}"
|
||||
@@ -784,13 +801,15 @@ class SolveResult:
|
||||
@overload
|
||||
def ray_reduced_costs(
|
||||
self, variables: None = ...
|
||||
) -> Dict[model.Variable, float]: ...
|
||||
) -> Dict[variables_mod.Variable, float]: ...
|
||||
|
||||
@overload
|
||||
def ray_reduced_costs(self, variables: model.Variable) -> float: ...
|
||||
def ray_reduced_costs(self, variables: variables_mod.Variable) -> float: ...
|
||||
|
||||
@overload
|
||||
def ray_reduced_costs(self, variables: Iterable[model.Variable]) -> List[float]: ...
|
||||
def ray_reduced_costs(
|
||||
self, variables: Iterable[variables_mod.Variable]
|
||||
) -> List[float]: ...
|
||||
|
||||
def ray_reduced_costs(self, variables=None):
|
||||
"""The reduced costs from the first dual ray.
|
||||
@@ -813,12 +832,13 @@ class SolveResult:
|
||||
"""
|
||||
if not self.has_dual_ray():
|
||||
raise ValueError("No dual ray available.")
|
||||
ray = self.dual_rays[0]
|
||||
if variables is None:
|
||||
return self.dual_rays[0].reduced_costs
|
||||
if isinstance(variables, model.Variable):
|
||||
return self.dual_rays[0].reduced_costs[variables]
|
||||
return ray.reduced_costs
|
||||
if isinstance(variables, variables_mod.Variable):
|
||||
return ray.reduced_costs[variables]
|
||||
if isinstance(variables, Iterable):
|
||||
return [self.dual_rays[0].reduced_costs[v] for v in variables]
|
||||
return [ray.reduced_costs[v] for v in variables]
|
||||
raise TypeError(
|
||||
"unsupported type in argument for "
|
||||
f"ray_reduced_costs: {type(variables).__name__!r}"
|
||||
@@ -841,16 +861,17 @@ class SolveResult:
|
||||
@overload
|
||||
def constraint_status(
|
||||
self, linear_constraints: None = ...
|
||||
) -> Dict[model.LinearConstraint, solution.BasisStatus]: ...
|
||||
) -> Dict[linear_constraints_mod.LinearConstraint, solution.BasisStatus]: ...
|
||||
|
||||
@overload
|
||||
def constraint_status(
|
||||
self, linear_constraints: model.LinearConstraint
|
||||
self, linear_constraints: linear_constraints_mod.LinearConstraint
|
||||
) -> solution.BasisStatus: ...
|
||||
|
||||
@overload
|
||||
def constraint_status(
|
||||
self, linear_constraints: Iterable[model.LinearConstraint]
|
||||
self,
|
||||
linear_constraints: Iterable[linear_constraints_mod.LinearConstraint],
|
||||
) -> List[solution.BasisStatus]: ...
|
||||
|
||||
def constraint_status(self, linear_constraints=None):
|
||||
@@ -880,15 +901,14 @@ class SolveResult:
|
||||
"""
|
||||
if not self.has_basis():
|
||||
raise ValueError(_NO_BASIS_ERROR)
|
||||
assert self.solutions[0].basis is not None
|
||||
basis = self.solutions[0].basis
|
||||
assert basis is not None
|
||||
if linear_constraints is None:
|
||||
return self.solutions[0].basis.constraint_status
|
||||
if isinstance(linear_constraints, model.LinearConstraint):
|
||||
return self.solutions[0].basis.constraint_status[linear_constraints]
|
||||
return basis.constraint_status
|
||||
if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint):
|
||||
return basis.constraint_status[linear_constraints]
|
||||
if isinstance(linear_constraints, Iterable):
|
||||
return [
|
||||
self.solutions[0].basis.constraint_status[c] for c in linear_constraints
|
||||
]
|
||||
return [basis.constraint_status[c] for c in linear_constraints]
|
||||
raise TypeError(
|
||||
"unsupported type in argument for "
|
||||
f"constraint_status: {type(linear_constraints).__name__!r}"
|
||||
@@ -897,14 +917,16 @@ class SolveResult:
|
||||
@overload
|
||||
def variable_status(
|
||||
self, variables: None = ...
|
||||
) -> Dict[model.Variable, solution.BasisStatus]: ...
|
||||
|
||||
@overload
|
||||
def variable_status(self, variables: model.Variable) -> solution.BasisStatus: ...
|
||||
) -> Dict[variables_mod.Variable, solution.BasisStatus]: ...
|
||||
|
||||
@overload
|
||||
def variable_status(
|
||||
self, variables: Iterable[model.Variable]
|
||||
self, variables: variables_mod.Variable
|
||||
) -> solution.BasisStatus: ...
|
||||
|
||||
@overload
|
||||
def variable_status(
|
||||
self, variables: Iterable[variables_mod.Variable]
|
||||
) -> List[solution.BasisStatus]: ...
|
||||
|
||||
def variable_status(self, variables=None):
|
||||
@@ -930,13 +952,14 @@ class SolveResult:
|
||||
"""
|
||||
if not self.has_basis():
|
||||
raise ValueError(_NO_BASIS_ERROR)
|
||||
assert self.solutions[0].basis is not None
|
||||
basis = self.solutions[0].basis
|
||||
assert basis is not None
|
||||
if variables is None:
|
||||
return self.solutions[0].basis.variable_status
|
||||
if isinstance(variables, model.Variable):
|
||||
return self.solutions[0].basis.variable_status[variables]
|
||||
return basis.variable_status
|
||||
if isinstance(variables, variables_mod.Variable):
|
||||
return basis.variable_status[variables]
|
||||
if isinstance(variables, Iterable):
|
||||
return [self.solutions[0].basis.variable_status[v] for v in variables]
|
||||
return [basis.variable_status[v] for v in variables]
|
||||
raise TypeError(
|
||||
"unsupported type in argument for "
|
||||
f"variable_status: {type(variables).__name__!r}"
|
||||
@@ -1008,7 +1031,10 @@ def _upgrade_termination(
|
||||
|
||||
|
||||
def parse_solve_result(
|
||||
proto: result_pb2.SolveResultProto, mod: model.Model
|
||||
proto: result_pb2.SolveResultProto,
|
||||
mod: model.Model,
|
||||
*,
|
||||
validate: bool = True,
|
||||
) -> SolveResult:
|
||||
"""Returns a SolveResult equivalent to the input proto."""
|
||||
result = SolveResult()
|
||||
@@ -1019,11 +1045,17 @@ def parse_solve_result(
|
||||
result.termination = parse_termination(_upgrade_termination(proto))
|
||||
result.solve_stats = parse_solve_stats(proto.solve_stats)
|
||||
for solution_proto in proto.solutions:
|
||||
result.solutions.append(solution.parse_solution(solution_proto, mod))
|
||||
result.solutions.append(
|
||||
solution.parse_solution(solution_proto, mod, validate=validate)
|
||||
)
|
||||
for primal_ray_proto in proto.primal_rays:
|
||||
result.primal_rays.append(solution.parse_primal_ray(primal_ray_proto, mod))
|
||||
result.primal_rays.append(
|
||||
solution.parse_primal_ray(primal_ray_proto, mod, validate=validate)
|
||||
)
|
||||
for dual_ray_proto in proto.dual_rays:
|
||||
result.dual_rays.append(solution.parse_dual_ray(dual_ray_proto, mod))
|
||||
result.dual_rays.append(
|
||||
solution.parse_dual_ray(dual_ray_proto, mod, validate=validate)
|
||||
)
|
||||
if proto.HasField("gscip_output"):
|
||||
result.gscip_specific_output = proto.gscip_output
|
||||
elif proto.HasField("osqp_output"):
|
||||
|
||||
@@ -1147,6 +1147,87 @@ class SolveResultTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
self.assert_protos_equiv(r.to_proto(), r_proto)
|
||||
self.assertEqual(result.parse_solve_result(r_proto, mod), r)
|
||||
|
||||
def test_solution_validation(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = result_pb2.SolveResultProto(
|
||||
termination=result_pb2.TerminationProto(
|
||||
reason=result_pb2.TERMINATION_REASON_OPTIMAL,
|
||||
problem_status=result_pb2.ProblemStatusProto(
|
||||
primal_status=result_pb2.FEASIBILITY_STATUS_FEASIBLE,
|
||||
dual_status=result_pb2.FEASIBILITY_STATUS_FEASIBLE,
|
||||
),
|
||||
),
|
||||
solutions=[
|
||||
solution_pb2.SolutionProto(
|
||||
primal_solution=solution_pb2.PrimalSolutionProto(
|
||||
variable_values=sparse_containers_pb2.SparseDoubleVectorProto(
|
||||
ids=[2], values=[4.0]
|
||||
),
|
||||
feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE,
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
res = result.parse_solve_result(proto, mod, validate=False)
|
||||
bad_var = mod.get_variable(2, validate=False)
|
||||
self.assertLen(res.solutions, 1)
|
||||
# TODO: b/215588365 - make a local variable so pytype is happy
|
||||
primal = res.solutions[0].primal_solution
|
||||
self.assertIsNotNone(primal)
|
||||
self.assertDictEqual(primal.variable_values, {bad_var: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
result.parse_solve_result(proto, mod, validate=True)
|
||||
|
||||
def test_primal_ray_validation(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = result_pb2.SolveResultProto(
|
||||
termination=result_pb2.TerminationProto(
|
||||
reason=result_pb2.TERMINATION_REASON_UNBOUNDED,
|
||||
problem_status=result_pb2.ProblemStatusProto(
|
||||
primal_status=result_pb2.FEASIBILITY_STATUS_FEASIBLE,
|
||||
dual_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE,
|
||||
),
|
||||
),
|
||||
primal_rays=[
|
||||
solution_pb2.PrimalRayProto(
|
||||
variable_values=sparse_containers_pb2.SparseDoubleVectorProto(
|
||||
ids=[2], values=[4.0]
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
res = result.parse_solve_result(proto, mod, validate=False)
|
||||
bad_var = mod.get_variable(2, validate=False)
|
||||
self.assertLen(res.primal_rays, 1)
|
||||
self.assertDictEqual(res.primal_rays[0].variable_values, {bad_var: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
result.parse_solve_result(proto, mod, validate=True)
|
||||
|
||||
def test_dual_ray_validation(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = result_pb2.SolveResultProto(
|
||||
termination=result_pb2.TerminationProto(
|
||||
reason=result_pb2.TERMINATION_REASON_INFEASIBLE,
|
||||
problem_status=result_pb2.ProblemStatusProto(
|
||||
primal_status=result_pb2.FEASIBILITY_STATUS_INFEASIBLE,
|
||||
dual_status=result_pb2.FEASIBILITY_STATUS_FEASIBLE,
|
||||
),
|
||||
),
|
||||
dual_rays=[
|
||||
solution_pb2.DualRayProto(
|
||||
reduced_costs=sparse_containers_pb2.SparseDoubleVectorProto(
|
||||
ids=[2], values=[4.0]
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
res = result.parse_solve_result(proto, mod, validate=False)
|
||||
bad_var = mod.get_variable(2, validate=False)
|
||||
self.assertLen(res.dual_rays, 1)
|
||||
self.assertDictEqual(res.dual_rays[0].reduced_costs, {bad_var: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
result.parse_solve_result(proto, mod, validate=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
|
||||
@@ -17,8 +17,12 @@ import enum
|
||||
from typing import Dict, Optional, TypeVar
|
||||
|
||||
from ortools.math_opt import solution_pb2
|
||||
from ortools.math_opt.python import linear_constraints
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import objectives
|
||||
from ortools.math_opt.python import quadratic_constraints
|
||||
from ortools.math_opt.python import sparse_containers
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
|
||||
@enum.unique
|
||||
@@ -95,14 +99,20 @@ class PrimalSolution:
|
||||
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.
|
||||
auxiliary_objective_values: Set only for multi objective problems, the
|
||||
objective value for each auxiliary objective, as computed by the solver.
|
||||
This value will not always be populated.
|
||||
feasibility_status: The feasibility of the solution as claimed by the
|
||||
solver.
|
||||
"""
|
||||
|
||||
variable_values: Dict[model.Variable, float] = dataclasses.field(
|
||||
variable_values: Dict[variables.Variable, float] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
objective_value: float = 0.0
|
||||
auxiliary_objective_values: Dict[objectives.AuxiliaryObjective, float] = (
|
||||
dataclasses.field(default_factory=dict)
|
||||
)
|
||||
feasibility_status: SolutionStatus = SolutionStatus.UNDETERMINED
|
||||
|
||||
def to_proto(self) -> solution_pb2.PrimalSolutionProto:
|
||||
@@ -112,18 +122,29 @@ class PrimalSolution:
|
||||
self.variable_values
|
||||
),
|
||||
objective_value=self.objective_value,
|
||||
auxiliary_objective_values={
|
||||
obj.id: obj_value
|
||||
for obj, obj_value in self.auxiliary_objective_values.items()
|
||||
},
|
||||
feasibility_status=self.feasibility_status.value,
|
||||
)
|
||||
|
||||
|
||||
def parse_primal_solution(
|
||||
proto: solution_pb2.PrimalSolutionProto, mod: model.Model
|
||||
proto: solution_pb2.PrimalSolutionProto,
|
||||
mod: model.Model,
|
||||
*,
|
||||
validate: bool = True,
|
||||
) -> PrimalSolution:
|
||||
"""Returns an equivalent PrimalSolution from the input proto."""
|
||||
result = PrimalSolution()
|
||||
result.objective_value = proto.objective_value
|
||||
for aux_id, obj_value in proto.auxiliary_objective_values.items():
|
||||
result.auxiliary_objective_values[
|
||||
mod.get_auxiliary_objective(aux_id, validate=validate)
|
||||
] = obj_value
|
||||
result.variable_values = sparse_containers.parse_variable_map(
|
||||
proto.variable_values, mod
|
||||
proto.variable_values, mod, validate=validate
|
||||
)
|
||||
status_proto = proto.feasibility_status
|
||||
if status_proto == solution_pb2.SOLUTION_STATUS_UNSPECIFIED:
|
||||
@@ -160,7 +181,7 @@ class PrimalRay:
|
||||
variable_values: The value assigned for each Variable in the model.
|
||||
"""
|
||||
|
||||
variable_values: Dict[model.Variable, float] = dataclasses.field(
|
||||
variable_values: Dict[variables.Variable, float] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
@@ -173,11 +194,16 @@ class PrimalRay:
|
||||
)
|
||||
|
||||
|
||||
def parse_primal_ray(proto: solution_pb2.PrimalRayProto, mod: model.Model) -> PrimalRay:
|
||||
def parse_primal_ray(
|
||||
proto: solution_pb2.PrimalRayProto,
|
||||
mod: model.Model,
|
||||
*,
|
||||
validate: bool = True,
|
||||
) -> PrimalRay:
|
||||
"""Returns an equivalent PrimalRay from the input proto."""
|
||||
result = PrimalRay()
|
||||
result.variable_values = sparse_containers.parse_variable_map(
|
||||
proto.variable_values, mod
|
||||
proto.variable_values, mod, validate=validate
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -201,6 +227,8 @@ class DualSolution:
|
||||
|
||||
Attributes:
|
||||
dual_values: The value assigned for each LinearConstraint in the model.
|
||||
quadratic_dual_values: The value assigned for each QuadraticConstraint 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.
|
||||
@@ -208,10 +236,15 @@ class DualSolution:
|
||||
solver.
|
||||
"""
|
||||
|
||||
dual_values: Dict[model.LinearConstraint, float] = dataclasses.field(
|
||||
dual_values: Dict[linear_constraints.LinearConstraint, float] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
quadratic_dual_values: Dict[quadratic_constraints.QuadraticConstraint, float] = (
|
||||
dataclasses.field(default_factory=dict)
|
||||
)
|
||||
reduced_costs: Dict[variables.Variable, 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
|
||||
|
||||
@@ -224,13 +257,19 @@ class DualSolution:
|
||||
reduced_costs=sparse_containers.to_sparse_double_vector_proto(
|
||||
self.reduced_costs
|
||||
),
|
||||
quadratic_dual_values=sparse_containers.to_sparse_double_vector_proto(
|
||||
self.quadratic_dual_values
|
||||
),
|
||||
objective_value=self.objective_value,
|
||||
feasibility_status=self.feasibility_status.value,
|
||||
)
|
||||
|
||||
|
||||
def parse_dual_solution(
|
||||
proto: solution_pb2.DualSolutionProto, mod: model.Model
|
||||
proto: solution_pb2.DualSolutionProto,
|
||||
mod: model.Model,
|
||||
*,
|
||||
validate: bool = True,
|
||||
) -> DualSolution:
|
||||
"""Returns an equivalent DualSolution from the input proto."""
|
||||
result = DualSolution()
|
||||
@@ -238,10 +277,13 @@ def parse_dual_solution(
|
||||
proto.objective_value if proto.HasField("objective_value") else None
|
||||
)
|
||||
result.dual_values = sparse_containers.parse_linear_constraint_map(
|
||||
proto.dual_values, mod
|
||||
proto.dual_values, mod, validate=validate
|
||||
)
|
||||
result.quadratic_dual_values = sparse_containers.parse_quadratic_constraint_map(
|
||||
proto.quadratic_dual_values, mod, validate=validate
|
||||
)
|
||||
result.reduced_costs = sparse_containers.parse_variable_map(
|
||||
proto.reduced_costs, mod
|
||||
proto.reduced_costs, mod, validate=validate
|
||||
)
|
||||
status_proto = proto.feasibility_status
|
||||
if status_proto == solution_pb2.SOLUTION_STATUS_UNSPECIFIED:
|
||||
@@ -281,10 +323,12 @@ class DualRay:
|
||||
reduced_costs: The value assigned for each Variable in the model.
|
||||
"""
|
||||
|
||||
dual_values: Dict[model.LinearConstraint, float] = dataclasses.field(
|
||||
dual_values: Dict[linear_constraints.LinearConstraint, float] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
reduced_costs: Dict[variables.Variable, float] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
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."""
|
||||
@@ -298,14 +342,16 @@ class DualRay:
|
||||
)
|
||||
|
||||
|
||||
def parse_dual_ray(proto: solution_pb2.DualRayProto, mod: model.Model) -> DualRay:
|
||||
def parse_dual_ray(
|
||||
proto: solution_pb2.DualRayProto, mod: model.Model, *, validate: bool = True
|
||||
) -> DualRay:
|
||||
"""Returns an equivalent DualRay from the input proto."""
|
||||
result = DualRay()
|
||||
result.dual_values = sparse_containers.parse_linear_constraint_map(
|
||||
proto.dual_values, mod
|
||||
proto.dual_values, mod, validate=validate
|
||||
)
|
||||
result.reduced_costs = sparse_containers.parse_variable_map(
|
||||
proto.reduced_costs, mod
|
||||
proto.reduced_costs, mod, validate=validate
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -358,11 +404,11 @@ class Basis:
|
||||
details see go/mathopt-basis-advanced#dualfeasibility.
|
||||
"""
|
||||
|
||||
variable_status: Dict[model.Variable, BasisStatus] = dataclasses.field(
|
||||
variable_status: Dict[variables.Variable, BasisStatus] = dataclasses.field(
|
||||
default_factory=dict
|
||||
)
|
||||
constraint_status: Dict[model.LinearConstraint, BasisStatus] = dataclasses.field(
|
||||
default_factory=dict
|
||||
constraint_status: Dict[linear_constraints.LinearConstraint, BasisStatus] = (
|
||||
dataclasses.field(default_factory=dict)
|
||||
)
|
||||
basic_dual_feasibility: Optional[SolutionStatus] = None
|
||||
|
||||
@@ -379,20 +425,24 @@ class Basis:
|
||||
)
|
||||
|
||||
|
||||
def parse_basis(proto: solution_pb2.BasisProto, mod: model.Model) -> Basis:
|
||||
def parse_basis(
|
||||
proto: solution_pb2.BasisProto, mod: model.Model, *, validate: bool = True
|
||||
) -> 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)
|
||||
result.variable_status[mod.get_variable(vid, validate=validate)] = 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
|
||||
result.constraint_status[mod.get_linear_constraint(cid, validate=validate)] = (
|
||||
BasisStatus(status_proto)
|
||||
)
|
||||
result.basic_dual_feasibility = parse_optional_solution_status(
|
||||
proto.basic_dual_feasibility
|
||||
@@ -400,11 +450,11 @@ def parse_basis(proto: solution_pb2.BasisProto, mod: model.Model) -> Basis:
|
||||
return result
|
||||
|
||||
|
||||
T = TypeVar("T", model.Variable, model.LinearConstraint)
|
||||
T = TypeVar("T", variables.Variable, linear_constraints.LinearConstraint)
|
||||
|
||||
|
||||
def _to_sparse_basis_status_vector_proto(
|
||||
terms: Dict[T, BasisStatus]
|
||||
terms: Dict[T, BasisStatus],
|
||||
) -> solution_pb2.SparseBasisStatusVector:
|
||||
"""Converts a basis vector from a python Dict to a protocol buffer."""
|
||||
result = solution_pb2.SparseBasisStatusVector()
|
||||
@@ -443,12 +493,25 @@ class Solution:
|
||||
)
|
||||
|
||||
|
||||
def parse_solution(proto: solution_pb2.SolutionProto, mod: model.Model) -> Solution:
|
||||
def parse_solution(
|
||||
proto: solution_pb2.SolutionProto,
|
||||
mod: model.Model,
|
||||
*,
|
||||
validate: bool = True,
|
||||
) -> 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)
|
||||
result.primal_solution = parse_primal_solution(
|
||||
proto.primal_solution, mod, validate=validate
|
||||
)
|
||||
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
|
||||
result.dual_solution = parse_dual_solution(
|
||||
proto.dual_solution, mod, validate=validate
|
||||
)
|
||||
result.basis = (
|
||||
parse_basis(proto.basis, mod, validate=validate)
|
||||
if proto.HasField("basis")
|
||||
else None
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
from absl.testing import absltest
|
||||
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 solution
|
||||
from ortools.math_opt.python.testing import compare_proto
|
||||
@@ -46,22 +47,26 @@ class ParsePrimalSolutionTest(compare_proto.MathOptProtoAssertions, absltest.Tes
|
||||
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)
|
||||
self.assertEmpty(round_trip_solution.auxiliary_objective_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")
|
||||
a = mod.add_auxiliary_objective(priority=1)
|
||||
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]
|
||||
proto.auxiliary_objective_values[0] = 12.1
|
||||
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.assertDictEqual(actual.auxiliary_objective_values, {a: 12.1})
|
||||
self.assert_protos_equiv(proto, actual.to_proto())
|
||||
|
||||
def test_primal_solution_unspecified_feasibility(self) -> None:
|
||||
@@ -75,6 +80,33 @@ class ParsePrimalSolutionTest(compare_proto.MathOptProtoAssertions, absltest.Tes
|
||||
):
|
||||
solution.parse_primal_solution(proto, mod)
|
||||
|
||||
def test_id_validation_variables(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.PrimalSolutionProto(
|
||||
objective_value=2.0,
|
||||
feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE,
|
||||
)
|
||||
proto.variable_values.ids[:] = [2]
|
||||
proto.variable_values.values[:] = [4.0]
|
||||
actual = solution.parse_primal_solution(proto, mod, validate=False)
|
||||
bad_var = mod.get_variable(2, validate=False)
|
||||
self.assertDictEqual(actual.variable_values, {bad_var: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_primal_solution(proto, mod, validate=True)
|
||||
|
||||
def test_id_validation_auxiliary_objectives(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.PrimalSolutionProto(
|
||||
objective_value=2.0,
|
||||
feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE,
|
||||
)
|
||||
proto.auxiliary_objective_values[2] = 12.1
|
||||
actual = solution.parse_primal_solution(proto, mod, validate=False)
|
||||
bad_obj = mod.get_auxiliary_objective(2, validate=False)
|
||||
self.assertDictEqual(actual.auxiliary_objective_values, {bad_obj: 12.1})
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_primal_solution(proto, mod, validate=True)
|
||||
|
||||
|
||||
class PrimalRayTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
|
||||
@@ -95,6 +127,17 @@ class PrimalRayTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
exported_ray = ray.to_proto()
|
||||
self.assert_protos_equiv(exported_ray, ray_proto)
|
||||
|
||||
def test_id_validation(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.PrimalRayProto()
|
||||
proto.variable_values.ids[:] = [2]
|
||||
proto.variable_values.values[:] = [4.0]
|
||||
actual = solution.parse_primal_ray(proto, mod, validate=False)
|
||||
bad_var = mod.get_variable(2, validate=False)
|
||||
self.assertDictEqual(actual.variable_values, {bad_var: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_primal_ray(proto, mod, validate=True)
|
||||
|
||||
|
||||
class ParseDualSolutionTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
|
||||
@@ -119,15 +162,20 @@ class ParseDualSolutionTest(compare_proto.MathOptProtoAssertions, absltest.TestC
|
||||
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")
|
||||
e = mod.add_quadratic_constraint(name="e")
|
||||
f = mod.add_quadratic_constraint(name="f")
|
||||
proto = solution_pb2.DualSolutionProto()
|
||||
proto.dual_values.ids[:] = [0, 1]
|
||||
proto.dual_values.values[:] = [0.0, 1.0]
|
||||
proto.quadratic_dual_values.ids[:] = [0, 1]
|
||||
proto.quadratic_dual_values.values[:] = [100.0, 101.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.assertDictEqual({e: 100, f: 101}, actual.quadratic_dual_values)
|
||||
self.assertIsNone(actual.objective_value)
|
||||
self.assertEqual(solution.SolutionStatus.FEASIBLE, actual.feasibility_status)
|
||||
self.assert_protos_equiv(proto, actual.to_proto())
|
||||
@@ -157,6 +205,48 @@ class ParseDualSolutionTest(compare_proto.MathOptProtoAssertions, absltest.TestC
|
||||
):
|
||||
solution.parse_dual_solution(proto, mod)
|
||||
|
||||
def test_id_validation_reduced_costs(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.DualSolutionProto(
|
||||
objective_value=2.0,
|
||||
feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE,
|
||||
)
|
||||
proto.reduced_costs.ids[:] = [2]
|
||||
proto.reduced_costs.values[:] = [4.0]
|
||||
actual = solution.parse_dual_solution(proto, mod, validate=False)
|
||||
bad_var = mod.get_variable(2, validate=False)
|
||||
self.assertDictEqual(actual.reduced_costs, {bad_var: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_dual_solution(proto, mod, validate=True)
|
||||
|
||||
def test_id_validation_dual_values(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.DualSolutionProto(
|
||||
objective_value=2.0,
|
||||
feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE,
|
||||
)
|
||||
proto.dual_values.ids[:] = [2]
|
||||
proto.dual_values.values[:] = [4.0]
|
||||
actual = solution.parse_dual_solution(proto, mod, validate=False)
|
||||
bad_lin_con = mod.get_linear_constraint(2, validate=False)
|
||||
self.assertDictEqual(actual.dual_values, {bad_lin_con: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_dual_solution(proto, mod, validate=True)
|
||||
|
||||
def test_id_validation_quadratic_dual_values(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.DualSolutionProto(
|
||||
objective_value=2.0,
|
||||
feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE,
|
||||
)
|
||||
proto.quadratic_dual_values.ids[:] = [2]
|
||||
proto.quadratic_dual_values.values[:] = [4.0]
|
||||
actual = solution.parse_dual_solution(proto, mod, validate=False)
|
||||
bad_quad_con = mod.get_quadratic_constraint(2, validate=False)
|
||||
self.assertDictEqual(actual.quadratic_dual_values, {bad_quad_con: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_dual_solution(proto, mod, validate=True)
|
||||
|
||||
|
||||
class DualRayTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
|
||||
@@ -186,6 +276,28 @@ class DualRayTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
exported_proto = dual_ray.to_proto()
|
||||
self.assert_protos_equiv(exported_proto, dual_ray_proto)
|
||||
|
||||
def test_id_validation_reduced_costs(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.DualRayProto()
|
||||
proto.reduced_costs.ids[:] = [2]
|
||||
proto.reduced_costs.values[:] = [4.0]
|
||||
actual = solution.parse_dual_ray(proto, mod, validate=False)
|
||||
bad_var = mod.get_variable(2, validate=False)
|
||||
self.assertDictEqual(actual.reduced_costs, {bad_var: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_dual_ray(proto, mod, validate=True)
|
||||
|
||||
def test_id_validation_dual_values(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.DualRayProto()
|
||||
proto.dual_values.ids[:] = [2]
|
||||
proto.dual_values.values[:] = [4.0]
|
||||
actual = solution.parse_dual_ray(proto, mod, validate=False)
|
||||
bad_lin_con = mod.get_linear_constraint(2, validate=False)
|
||||
self.assertDictEqual(actual.dual_values, {bad_lin_con: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_dual_ray(proto, mod, validate=True)
|
||||
|
||||
|
||||
class BasisTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
|
||||
@@ -284,6 +396,36 @@ class BasisTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
basis = solution.parse_basis(basis_proto, mod)
|
||||
self.assertIsNone(basis.basic_dual_feasibility)
|
||||
|
||||
def test_variable_id_validation(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
basis_proto = solution_pb2.BasisProto()
|
||||
basis_proto.variable_status.ids[:] = [2]
|
||||
basis_proto.variable_status.values[:] = [
|
||||
solution_pb2.BASIS_STATUS_BASIC,
|
||||
]
|
||||
basis = solution.parse_basis(basis_proto, mod, validate=False)
|
||||
bad_var = mod.get_variable(2, validate=False)
|
||||
self.assertDictEqual(
|
||||
basis.variable_status, {bad_var: solution.BasisStatus.BASIC}
|
||||
)
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_basis(basis_proto, mod, validate=True)
|
||||
|
||||
def test_linear_constraint_id_validation(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
basis_proto = solution_pb2.BasisProto()
|
||||
basis_proto.constraint_status.ids[:] = [2]
|
||||
basis_proto.constraint_status.values[:] = [
|
||||
solution_pb2.BASIS_STATUS_BASIC,
|
||||
]
|
||||
basis = solution.parse_basis(basis_proto, mod, validate=False)
|
||||
bad_con = mod.get_linear_constraint(2, validate=False)
|
||||
self.assertDictEqual(
|
||||
basis.constraint_status, {bad_con: solution.BasisStatus.BASIC}
|
||||
)
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_basis(basis_proto, mod, validate=True)
|
||||
|
||||
|
||||
class ParseSolutionTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
|
||||
@@ -327,6 +469,64 @@ class ParseSolutionTest(compare_proto.MathOptProtoAssertions, absltest.TestCase)
|
||||
actual = solution.parse_solution(proto, mod)
|
||||
self.assert_protos_equiv(proto, actual.to_proto())
|
||||
|
||||
def test_basis_id_validation(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.SolutionProto(
|
||||
basis=solution_pb2.BasisProto(
|
||||
constraint_status=solution_pb2.SparseBasisStatusVector(
|
||||
ids=[2], values=[solution_pb2.BASIS_STATUS_BASIC]
|
||||
)
|
||||
)
|
||||
)
|
||||
sol = solution.parse_solution(proto, mod, validate=False)
|
||||
bad_con = mod.get_linear_constraint(2, validate=False)
|
||||
# TODO: b/215588365 - make a local variable so pytype is happy
|
||||
basis = sol.basis
|
||||
self.assertIsNotNone(basis)
|
||||
self.assertDictEqual(
|
||||
basis.constraint_status, {bad_con: solution.BasisStatus.BASIC}
|
||||
)
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_solution(proto, mod, validate=True)
|
||||
|
||||
def test_primal_solution_id_validation(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.SolutionProto(
|
||||
primal_solution=solution_pb2.PrimalSolutionProto(
|
||||
variable_values=sparse_containers_pb2.SparseDoubleVectorProto(
|
||||
ids=[2], values=[4.0]
|
||||
),
|
||||
feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE,
|
||||
)
|
||||
)
|
||||
sol = solution.parse_solution(proto, mod, validate=False)
|
||||
bad_var = mod.get_variable(2, validate=False)
|
||||
# TODO: b/215588365 - make a local variable so pytype is happy
|
||||
primal = sol.primal_solution
|
||||
self.assertIsNotNone(primal)
|
||||
self.assertDictEqual(primal.variable_values, {bad_var: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_solution(proto, mod, validate=True)
|
||||
|
||||
def test_dual_solution_id_validation(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
proto = solution_pb2.SolutionProto(
|
||||
dual_solution=solution_pb2.DualSolutionProto(
|
||||
reduced_costs=sparse_containers_pb2.SparseDoubleVectorProto(
|
||||
ids=[2], values=[4.0]
|
||||
),
|
||||
feasibility_status=solution_pb2.SOLUTION_STATUS_FEASIBLE,
|
||||
)
|
||||
)
|
||||
sol = solution.parse_solution(proto, mod, validate=False)
|
||||
bad_var = mod.get_variable(2, validate=False)
|
||||
# TODO: b/215588365 - make a local variable so pytype is happy
|
||||
dual = sol.dual_solution
|
||||
self.assertIsNotNone(dual)
|
||||
self.assertDictEqual(dual.reduced_costs, {bad_var: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
solution.parse_solution(proto, mod, validate=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
|
||||
@@ -95,7 +95,7 @@ def solve(
|
||||
)
|
||||
except StatusNotOk as e:
|
||||
raise _status_not_ok_to_exception(e) from None
|
||||
return result.parse_solve_result(proto_result, opt_model)
|
||||
return result.parse_solve_result(proto_result, opt_model, validate=False)
|
||||
|
||||
|
||||
def compute_infeasible_subsystem(
|
||||
@@ -257,7 +257,7 @@ class IncrementalSolver:
|
||||
)
|
||||
except StatusNotOk as e:
|
||||
raise _status_not_ok_to_exception(e) from None
|
||||
return result.parse_solve_result(result_proto, self._model)
|
||||
return result.parse_solve_result(result_proto, self._model, validate=False)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Closes this solver, freeing all its resources.
|
||||
|
||||
@@ -24,9 +24,11 @@ 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 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
|
||||
|
||||
|
||||
_Bounds = compute_infeasible_subsystem_result.ModelSubsetBounds
|
||||
@@ -83,6 +85,87 @@ class SolveTest(absltest.TestCase):
|
||||
)
|
||||
self.assertAlmostEqual(2.0, res.termination.objective_bounds.primal_bound)
|
||||
|
||||
def test_hierarchical_objectives(self) -> None:
|
||||
mod = model.Model()
|
||||
# The model is:
|
||||
# max x + y + 2 z
|
||||
# s.t. x + y + z <= 1.5
|
||||
# x, y in [0, 1]
|
||||
# z binary
|
||||
# With secondary objective
|
||||
# max y
|
||||
#
|
||||
# The first problem is solved by any convex combination of:
|
||||
# (0.5, 0, 1) and (0, 0.5, 1)
|
||||
# But with the secondary objective, the unique solution is (0, 0.5, 1), with
|
||||
# a primary objective value of 2.5 and secondary objective value of 0.5.
|
||||
x = mod.add_variable(lb=0, ub=1)
|
||||
y = mod.add_variable(lb=0, ub=1)
|
||||
z = mod.add_binary_variable()
|
||||
mod.add_linear_constraint(x + y + z <= 1.5)
|
||||
mod.maximize(x + y + 2 * z)
|
||||
aux = mod.add_maximization_objective(y, priority=1)
|
||||
|
||||
res = solve.solve(mod, parameters.SolverType.GUROBI)
|
||||
self.assertEqual(
|
||||
res.termination.reason,
|
||||
result.TerminationReason.OPTIMAL,
|
||||
msg=res.termination,
|
||||
)
|
||||
self.assertAlmostEqual(res.objective_value(), 2.5, delta=1e-4)
|
||||
self.assertAlmostEqual(res.variable_values(x), 0.0, delta=1e-4)
|
||||
self.assertAlmostEqual(res.variable_values(y), 0.5, delta=1e-4)
|
||||
self.assertAlmostEqual(res.variable_values(z), 1.0, delta=1e-4)
|
||||
prim_sol = res.solutions[0].primal_solution
|
||||
self.assertIsNotNone(prim_sol)
|
||||
self.assertDictEqual(prim_sol.auxiliary_objective_values, {aux: 0.5})
|
||||
|
||||
def test_quadratic_dual(self) -> None:
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
mod.minimize(x)
|
||||
c = mod.add_quadratic_constraint(expr=x * x, ub=1.0)
|
||||
params = parameters.SolveParameters()
|
||||
params.gurobi.param_values["QCPDual"] = "1"
|
||||
res = solve.solve(mod, parameters.SolverType.GUROBI, params=params)
|
||||
self.assertEqual(res.termination.reason, result.TerminationReason.OPTIMAL)
|
||||
sol = res.solutions[0]
|
||||
primal = sol.primal_solution
|
||||
dual = sol.dual_solution
|
||||
self.assertIsNotNone(primal)
|
||||
self.assertIsNotNone(dual)
|
||||
self.assertAlmostEqual(primal.variable_values[x], -1.0)
|
||||
self.assertAlmostEqual(dual.quadratic_dual_values[c], -0.5)
|
||||
|
||||
def test_quadratic_dual_filter(self) -> None:
|
||||
# Same as the previous test, but now with a filter on the quadratic duals
|
||||
# that are returned.
|
||||
mod = model.Model()
|
||||
x = mod.add_variable()
|
||||
mod.minimize(x)
|
||||
mod.add_quadratic_constraint(expr=x * x, ub=1.0)
|
||||
params = parameters.SolveParameters()
|
||||
params.gurobi.param_values["QCPDual"] = "1"
|
||||
mod_params = model_parameters.ModelSolveParameters(
|
||||
quadratic_dual_values_filter=sparse_containers.QuadraticConstraintFilter(
|
||||
filtered_items={}
|
||||
)
|
||||
)
|
||||
res = solve.solve(
|
||||
mod,
|
||||
parameters.SolverType.GUROBI,
|
||||
params=params,
|
||||
model_params=mod_params,
|
||||
)
|
||||
self.assertEqual(res.termination.reason, result.TerminationReason.OPTIMAL)
|
||||
sol = res.solutions[0]
|
||||
primal = sol.primal_solution
|
||||
dual = sol.dual_solution
|
||||
self.assertIsNotNone(primal)
|
||||
self.assertIsNotNone(dual)
|
||||
self.assertAlmostEqual(primal.variable_values[x], -1.0)
|
||||
self.assertEmpty(dual.quadratic_dual_values)
|
||||
|
||||
def test_compute_infeasible_subsystem_infeasible(self):
|
||||
mod = model.Model()
|
||||
x = mod.add_variable(lb=0.0, ub=1.0)
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing import Dict, List, Union
|
||||
|
||||
from absl.testing import absltest
|
||||
from ortools.math_opt.core.python import solver as core_solver
|
||||
from ortools.math_opt.python import linear_constraints
|
||||
from ortools.math_opt.python import message_callback
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import model_parameters
|
||||
@@ -24,9 +25,11 @@ 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
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
VarOrConstraintDict = Union[
|
||||
Dict[model.Variable, float], Dict[model.LinearConstraint, float]
|
||||
Dict[variables.Variable, float],
|
||||
Dict[linear_constraints.LinearConstraint, float],
|
||||
]
|
||||
|
||||
# This string appears in the logs if and only if SCIP is doing an incremental
|
||||
@@ -132,6 +135,29 @@ class SolveTest(absltest.TestCase):
|
||||
msg=f"dual_vec is {dual_vec}; expected {expected1} or {expected2}",
|
||||
)
|
||||
|
||||
def test_indicator(self) -> None:
|
||||
# min 2 * x + y + 10 z
|
||||
# s.t. if not z then x + y >= 6
|
||||
# x, y >= 0
|
||||
# z binary
|
||||
#
|
||||
# Optimal solution is (x, y, z) = (0, 6, 0), objective value 6.
|
||||
mod = model.Model()
|
||||
x = mod.add_variable(lb=0)
|
||||
y = mod.add_variable(lb=0)
|
||||
z = mod.add_binary_variable()
|
||||
mod.add_indicator_constraint(
|
||||
indicator=z, activate_on_zero=True, implied_constraint=x + y >= 6.0
|
||||
)
|
||||
mod.minimize(2 * x + y + 10 * z)
|
||||
|
||||
res = solve.solve(mod, parameters.SolverType.GSCIP)
|
||||
self.assertEqual(res.termination.reason, result.TerminationReason.OPTIMAL)
|
||||
self.assertAlmostEqual(res.objective_value(), 6.0, delta=1e-5)
|
||||
self.assertAlmostEqual(res.variable_values(x), 0.0, delta=1e-5)
|
||||
self.assertAlmostEqual(res.variable_values(y), 6.0, delta=1e-5)
|
||||
self.assertAlmostEqual(res.variable_values(z), 0.0, delta=1e-5)
|
||||
|
||||
def test_filters(self) -> None:
|
||||
mod = model.Model(name="test_model")
|
||||
# Solve the problem:
|
||||
|
||||
@@ -18,16 +18,22 @@ 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 linear_constraints
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import quadratic_constraints
|
||||
from ortools.math_opt.python import variables
|
||||
|
||||
|
||||
VarOrConstraintType = TypeVar(
|
||||
"VarOrConstraintType", model.Variable, model.LinearConstraint
|
||||
"VarOrConstraintType",
|
||||
variables.Variable,
|
||||
linear_constraints.LinearConstraint,
|
||||
quadratic_constraints.QuadraticConstraint,
|
||||
)
|
||||
|
||||
|
||||
def to_sparse_double_vector_proto(
|
||||
terms: Mapping[VarOrConstraintType, float]
|
||||
terms: Mapping[VarOrConstraintType, float],
|
||||
) -> sparse_containers_pb2.SparseDoubleVectorProto:
|
||||
"""Converts a sparse vector from proto to dict representation."""
|
||||
result = sparse_containers_pb2.SparseDoubleVectorProto()
|
||||
@@ -41,7 +47,7 @@ def to_sparse_double_vector_proto(
|
||||
|
||||
|
||||
def to_sparse_int32_vector_proto(
|
||||
terms: Mapping[VarOrConstraintType, int]
|
||||
terms: Mapping[VarOrConstraintType, int],
|
||||
) -> sparse_containers_pb2.SparseInt32VectorProto:
|
||||
"""Converts a sparse vector from proto to dict representation."""
|
||||
result = sparse_containers_pb2.SparseInt32VectorProto()
|
||||
@@ -55,22 +61,42 @@ def to_sparse_int32_vector_proto(
|
||||
|
||||
|
||||
def parse_variable_map(
|
||||
proto: sparse_containers_pb2.SparseDoubleVectorProto, mod: model.Model
|
||||
) -> Dict[model.Variable, float]:
|
||||
proto: sparse_containers_pb2.SparseDoubleVectorProto,
|
||||
mod: model.Model,
|
||||
validate: bool = True,
|
||||
) -> Dict[variables.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]
|
||||
result[mod.get_variable(var_id, validate=validate)] = proto.values[index]
|
||||
return result
|
||||
|
||||
|
||||
def parse_linear_constraint_map(
|
||||
proto: sparse_containers_pb2.SparseDoubleVectorProto, mod: model.Model
|
||||
) -> Dict[model.LinearConstraint, float]:
|
||||
proto: sparse_containers_pb2.SparseDoubleVectorProto,
|
||||
mod: model.Model,
|
||||
validate: bool = True,
|
||||
) -> Dict[linear_constraints.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]
|
||||
result[mod.get_linear_constraint(lin_con_id, validate=validate)] = proto.values[
|
||||
index
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def parse_quadratic_constraint_map(
|
||||
proto: sparse_containers_pb2.SparseDoubleVectorProto,
|
||||
mod: model.Model,
|
||||
validate: bool = True,
|
||||
) -> Dict[quadratic_constraints.QuadraticConstraint, float]:
|
||||
"""Converts a sparse vector of quadratic constraints from proto to dict representation."""
|
||||
result = {}
|
||||
for index, quad_con_id in enumerate(proto.ids):
|
||||
result[mod.get_quadratic_constraint(quad_con_id, validate=validate)] = (
|
||||
proto.values[index]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -110,7 +136,7 @@ class SparseVectorFilter(Generic[VarOrConstraintType]):
|
||||
self._filtered_items
|
||||
) # pytype: disable=bad-return-type # attribute-variable-annotations
|
||||
|
||||
def to_proto(self):
|
||||
def to_proto(self) -> sparse_containers_pb2.SparseVectorFilterProto:
|
||||
"""Returns an equivalent proto representation."""
|
||||
result = sparse_containers_pb2.SparseVectorFilterProto()
|
||||
result.skip_zero_values = self._skip_zero_values
|
||||
@@ -120,5 +146,8 @@ class SparseVectorFilter(Generic[VarOrConstraintType]):
|
||||
return result
|
||||
|
||||
|
||||
VariableFilter = SparseVectorFilter[model.Variable]
|
||||
LinearConstraintFilter = SparseVectorFilter[model.LinearConstraint]
|
||||
VariableFilter = SparseVectorFilter[variables.Variable]
|
||||
LinearConstraintFilter = SparseVectorFilter[linear_constraints.LinearConstraint]
|
||||
QuadraticConstraintFilter = SparseVectorFilter[
|
||||
quadratic_constraints.QuadraticConstraint
|
||||
]
|
||||
|
||||
@@ -12,10 +12,18 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from collections.abc import Callable
|
||||
import dataclasses
|
||||
from typing import Dict, Generic, Protocol, TypeVar, Union
|
||||
|
||||
from absl.testing import absltest
|
||||
from absl.testing import parameterized
|
||||
from ortools.math_opt import sparse_containers_pb2
|
||||
from ortools.math_opt.python import linear_constraints
|
||||
from ortools.math_opt.python import model
|
||||
from ortools.math_opt.python import quadratic_constraints
|
||||
from ortools.math_opt.python import sparse_containers
|
||||
from ortools.math_opt.python import variables
|
||||
from ortools.math_opt.python.testing import compare_proto
|
||||
|
||||
|
||||
@@ -50,12 +58,67 @@ class SparseDoubleVectorTest(compare_proto.MathOptProtoAssertions, absltest.Test
|
||||
),
|
||||
)
|
||||
|
||||
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(
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
# We cannot use Callable here because we need to support a named argument.
|
||||
class ParseMap(Protocol, Generic[T]):
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
vec: sparse_containers_pb2.SparseDoubleVectorProto,
|
||||
mod: model.Model,
|
||||
*,
|
||||
validate: bool = True,
|
||||
) -> Dict[T, float]: ...
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ParseMapAdapater(Generic[T]):
|
||||
add_element: Callable[[model.Model], T]
|
||||
get_element_no_validate: Callable[[model.Model, int], T]
|
||||
parse_map: ParseMap[T]
|
||||
|
||||
|
||||
_VAR_ADAPTER = ParseMapAdapater(
|
||||
model.Model.add_variable,
|
||||
lambda mod, id: mod.get_variable(id, validate=False),
|
||||
sparse_containers.parse_variable_map,
|
||||
)
|
||||
_LIN_CON_ADAPTER = ParseMapAdapater(
|
||||
model.Model.add_linear_constraint,
|
||||
lambda mod, id: mod.get_linear_constraint(id, validate=False),
|
||||
sparse_containers.parse_linear_constraint_map,
|
||||
)
|
||||
_QUAD_CON_ADAPTER = ParseMapAdapater(
|
||||
model.Model.add_quadratic_constraint,
|
||||
lambda mod, id: mod.get_quadratic_constraint(id, validate=False),
|
||||
sparse_containers.parse_quadratic_constraint_map,
|
||||
)
|
||||
|
||||
_ADAPTERS = Union[
|
||||
ParseMapAdapater[variables.Variable],
|
||||
ParseMapAdapater[linear_constraints.LinearConstraint],
|
||||
ParseMapAdapater[quadratic_constraints.QuadraticConstraint],
|
||||
]
|
||||
|
||||
|
||||
@parameterized.named_parameters(
|
||||
("variable", _VAR_ADAPTER),
|
||||
("linear_constraint", _LIN_CON_ADAPTER),
|
||||
("quadratic_constraint", _QUAD_CON_ADAPTER),
|
||||
)
|
||||
class ParseVariableMapTest(
|
||||
compare_proto.MathOptProtoAssertions, parameterized.TestCase
|
||||
):
|
||||
|
||||
def test_parse_map(self, adapter: _ADAPTERS) -> None:
|
||||
mod = model.Model()
|
||||
x = adapter.add_element(mod)
|
||||
adapter.add_element(mod)
|
||||
z = adapter.add_element(mod)
|
||||
actual = adapter.parse_map(
|
||||
sparse_containers_pb2.SparseDoubleVectorProto(
|
||||
ids=[0, 2], values=[1.0, 4.0]
|
||||
),
|
||||
@@ -63,38 +126,21 @@ class SparseDoubleVectorTest(compare_proto.MathOptProtoAssertions, absltest.Test
|
||||
)
|
||||
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
|
||||
)
|
||||
def test_parse_map_empty(self, adapter: _ADAPTERS) -> None:
|
||||
mod = model.Model()
|
||||
adapter.add_element(mod)
|
||||
adapter.add_element(mod)
|
||||
actual = adapter.parse_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, {})
|
||||
def test_parse_var_map_bad_var(self, adapter: _ADAPTERS) -> None:
|
||||
mod = model.Model()
|
||||
bad_proto = sparse_containers_pb2.SparseDoubleVectorProto(ids=[2], values=[4.0])
|
||||
actual = adapter.parse_map(bad_proto, mod, validate=False)
|
||||
bad_elem = adapter.get_element_no_validate(mod, 2)
|
||||
self.assertDictEqual(actual, {bad_elem: 4.0})
|
||||
with self.assertRaises(KeyError):
|
||||
adapter.parse_map(bad_proto, mod, validate=True)
|
||||
|
||||
|
||||
class SparseInt32VectorTest(compare_proto.MathOptProtoAssertions, absltest.TestCase):
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for statistics."""
|
||||
|
||||
import math
|
||||
|
||||
from absl.testing import absltest
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
load("@rules_python//python:defs.bzl", "py_library")
|
||||
load("@pip_deps//:requirements.bzl", "requirement")
|
||||
load("@rules_python//python:defs.bzl", "py_library", "py_test")
|
||||
|
||||
package(default_visibility = ["//ortools/math_opt:__subpackages__"])
|
||||
|
||||
@@ -20,11 +21,23 @@ py_library(
|
||||
testonly = 1,
|
||||
srcs = ["compare_proto.py"],
|
||||
deps = [
|
||||
requirement("absl-py"),
|
||||
"//ortools/math_opt/python:normalize",
|
||||
"@com_google_protobuf//:protobuf_python",
|
||||
],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "compare_proto_test",
|
||||
srcs = ["compare_proto_test.py"],
|
||||
deps = [
|
||||
":compare_proto",
|
||||
requirement("absl-py"),
|
||||
"//ortools/math_opt:model_py_pb2",
|
||||
"//ortools/math_opt/python:normalize",
|
||||
],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "proto_matcher",
|
||||
testonly = 1,
|
||||
@@ -34,3 +47,14 @@ py_library(
|
||||
"@com_google_protobuf//:protobuf_python",
|
||||
],
|
||||
)
|
||||
|
||||
py_test(
|
||||
name = "proto_matcher_test",
|
||||
srcs = ["proto_matcher_test.py"],
|
||||
deps = [
|
||||
":proto_matcher",
|
||||
requirement("absl-py"),
|
||||
"//ortools/math_opt:model_update_py_pb2",
|
||||
"//ortools/math_opt:sparse_containers_py_pb2",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -11,8 +11,22 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Assertions to test that protos are equivalent in MathOpt's sense.
|
||||
"""Assertions to compare protocol buffers.
|
||||
|
||||
Two comparisons are provided:
|
||||
|
||||
1. assert_protos_equal(): Checks that two messages are exactly the same (roughly
|
||||
speaking, they should produce the same binary representation).
|
||||
2. assert_protos_equiv(): Checks that two messages are equivalent in the MathOpt
|
||||
sense (roughly speaking, empty submessages being set/unset do not matter,
|
||||
details below).
|
||||
|
||||
(2) is reduced to (1) by recursively clearing empty messages via normalize.py.
|
||||
To implement (1),
|
||||
in open source we check that the text proto representations are the same (this
|
||||
is a slightly weaker test).
|
||||
|
||||
The exact contract for assert_protos_equiv() is as follows.
|
||||
Empty sub-messages (recursively) have no effect on equivalence, except for the
|
||||
Duration message, otherwise the same as proto equality. Oneof messages have
|
||||
their fields recursively cleared, but the oneof itself is not, to preserve the
|
||||
@@ -34,8 +48,19 @@ from google.protobuf import message
|
||||
from ortools.math_opt.python import normalize
|
||||
|
||||
|
||||
def assert_protos_equal(
|
||||
test: unittest.TestCase,
|
||||
actual: message.Message,
|
||||
expected: message.Message,
|
||||
) -> None:
|
||||
"""Asserts the input protos are equal, see module doc for details."""
|
||||
test.assertEqual(str(actual), str(expected))
|
||||
|
||||
|
||||
def assert_protos_equiv(
|
||||
test: unittest.TestCase, actual: message.Message, expected: message.Message
|
||||
test: unittest.TestCase,
|
||||
actual: message.Message,
|
||||
expected: message.Message,
|
||||
) -> None:
|
||||
"""Asserts the input protos are equivalent, see module doc for details."""
|
||||
normalized_actual = copy.deepcopy(actual)
|
||||
@@ -48,8 +73,18 @@ def assert_protos_equiv(
|
||||
class MathOptProtoAssertions(unittest.TestCase):
|
||||
"""Provides a custom MathOpt proto equivalence assertion for tests."""
|
||||
|
||||
def assert_protos_equal(
|
||||
self,
|
||||
actual: message.Message,
|
||||
expected: message.Message,
|
||||
) -> None:
|
||||
"""Asserts the input protos are equal, see module doc for details."""
|
||||
return assert_protos_equal(self, actual, expected)
|
||||
|
||||
def assert_protos_equiv(
|
||||
self, actual: message.Message, expected: message.Message
|
||||
self,
|
||||
actual: message.Message,
|
||||
expected: message.Message,
|
||||
) -> None:
|
||||
"""Asserts the input protos are equivalent, see module doc for details."""
|
||||
return assert_protos_equiv(self, actual, expected)
|
||||
|
||||
61
ortools/math_opt/python/testing/compare_proto_test.py
Normal file
61
ortools/math_opt/python/testing/compare_proto_test.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# Copyright 2010-2025 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 MathOpt's custom proto test assertions."""
|
||||
|
||||
from absl.testing import absltest
|
||||
from ortools.math_opt import model_pb2
|
||||
from ortools.math_opt.python import normalize
|
||||
from ortools.math_opt.python.testing import compare_proto
|
||||
|
||||
|
||||
class MathOptProtoAssertionsTest(
|
||||
absltest.TestCase, compare_proto.MathOptProtoAssertions
|
||||
):
|
||||
|
||||
def test_assertions_match_but_not_equal(self) -> None:
|
||||
model_with_empty_vars = model_pb2.ModelProto()
|
||||
model_with_empty_vars.variables.SetInParent()
|
||||
empty = model_pb2.ModelProto()
|
||||
with self.assertRaisesRegex(AssertionError, ".*variables.*"):
|
||||
self.assert_protos_equal(model_with_empty_vars, empty)
|
||||
with self.assertRaisesRegex(AssertionError, ".*variables.*"):
|
||||
self.assert_protos_equal(empty, model_with_empty_vars)
|
||||
|
||||
self.assert_protos_equiv(model_with_empty_vars, empty)
|
||||
self.assert_protos_equiv(empty, model_with_empty_vars)
|
||||
|
||||
normalize.math_opt_normalize_proto(model_with_empty_vars)
|
||||
self.assert_protos_equal(model_with_empty_vars, empty)
|
||||
self.assert_protos_equal(empty, model_with_empty_vars)
|
||||
self.assert_protos_equiv(model_with_empty_vars, empty)
|
||||
self.assert_protos_equiv(empty, model_with_empty_vars)
|
||||
|
||||
def test_do_not_match(self) -> None:
|
||||
with_maximize = model_pb2.ModelProto()
|
||||
with_maximize.objective.maximize = True
|
||||
empty = model_pb2.ModelProto()
|
||||
|
||||
with self.assertRaisesRegex(AssertionError, ".*maximize.*"):
|
||||
self.assert_protos_equal(with_maximize, empty)
|
||||
with self.assertRaisesRegex(AssertionError, ".*maximize.*"):
|
||||
self.assert_protos_equal(empty, with_maximize)
|
||||
|
||||
with self.assertRaisesRegex(AssertionError, ".*maximize.*"):
|
||||
self.assert_protos_equiv(with_maximize, empty)
|
||||
with self.assertRaisesRegex(AssertionError, ".*maximize.*"):
|
||||
self.assert_protos_equiv(empty, with_maximize)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
66
ortools/math_opt/python/testing/proto_matcher_test.py
Normal file
66
ortools/math_opt/python/testing/proto_matcher_test.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# Copyright 2010-2025 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 unittest import mock
|
||||
|
||||
from absl.testing import absltest
|
||||
from ortools.math_opt import model_update_pb2
|
||||
from ortools.math_opt import sparse_containers_pb2
|
||||
from ortools.math_opt.python.testing import proto_matcher
|
||||
|
||||
|
||||
class _ConsumesUpdate:
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def on_update(self, update: model_update_pb2.ModelUpdateProto):
|
||||
pass
|
||||
|
||||
|
||||
class MathOptProtoAssertionsTest(absltest.TestCase):
|
||||
|
||||
def test_mock_eq(self):
|
||||
update1 = model_update_pb2.ObjectiveUpdatesProto(
|
||||
direction_update=True, offset_update=0.0
|
||||
)
|
||||
update2 = model_update_pb2.ObjectiveUpdatesProto(direction_update=True)
|
||||
update3 = model_update_pb2.ObjectiveUpdatesProto(
|
||||
direction_update=True,
|
||||
linear_coefficients=sparse_containers_pb2.SparseDoubleVectorProto(),
|
||||
)
|
||||
self.assertFalse(
|
||||
proto_matcher.MathOptProtoEquivMatcher(update1).__eq__(update2)
|
||||
)
|
||||
self.assertTrue(proto_matcher.MathOptProtoEquivMatcher(update1).__ne__(update2))
|
||||
self.assertTrue(proto_matcher.MathOptProtoEquivMatcher(update2).__eq__(update3))
|
||||
self.assertFalse(
|
||||
proto_matcher.MathOptProtoEquivMatcher(update2).__ne__(update3)
|
||||
)
|
||||
|
||||
def test_mock_function_when_equal(self):
|
||||
consumer = _ConsumesUpdate()
|
||||
consumer.on_update = mock.MagicMock()
|
||||
|
||||
update = model_update_pb2.ModelUpdateProto(deleted_variable_ids=[0, 4, 5])
|
||||
|
||||
consumer.on_update(update)
|
||||
|
||||
expected = model_update_pb2.ModelUpdateProto(deleted_variable_ids=[0, 4, 5])
|
||||
consumer.on_update.assert_called_with(
|
||||
proto_matcher.MathOptProtoEquivMatcher(expected)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
absltest.main()
|
||||
1418
ortools/math_opt/python/variables.py
Normal file
1418
ortools/math_opt/python/variables.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user