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:
Corentin Le Molgat
2022-12-16 17:06:11 +01:00
parent 178084b3f7
commit 5bf70b691f
245 changed files with 28953 additions and 5680 deletions

View File

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

View File

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

View 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}"

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

View File

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

View File

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

View File

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

View File

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

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

View 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).
"""

View File

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

View File

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

View 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}'."
)

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

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

@@ -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__":

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff