diff --git a/cmake/python.cmake b/cmake/python.cmake index 79f46f27a6..5eb3426f84 100644 --- a/cmake/python.cmake +++ b/cmake/python.cmake @@ -311,6 +311,7 @@ if(BUILD_MATH_OPT) file(GENERATE OUTPUT ${PYTHON_PROJECT_DIR}/math_opt/core/__init__.py CONTENT "") file(GENERATE OUTPUT ${PYTHON_PROJECT_DIR}/math_opt/core/python/__init__.py CONTENT "") file(GENERATE OUTPUT ${PYTHON_PROJECT_DIR}/math_opt/python/__init__.py CONTENT "") + file(GENERATE OUTPUT ${PYTHON_PROJECT_DIR}/math_opt/python/testing/__init__.py CONTENT "") file(GENERATE OUTPUT ${PYTHON_PROJECT_DIR}/math_opt/solvers/__init__.py CONTENT "") endif() file(GENERATE OUTPUT ${PYTHON_PROJECT_DIR}/packing/__init__.py CONTENT "") @@ -352,6 +353,10 @@ if(BUILD_MATH_OPT) ortools/math_opt/python/sparse_containers.py ortools/math_opt/python/statistics.py DESTINATION ${PYTHON_PROJECT_DIR}/math_opt/python) + file(COPY + ortools/math_opt/python/testing/compare_proto.py + ortools/math_opt/python/testing/proto_matcher.py + DESTINATION ${PYTHON_PROJECT_DIR}/math_opt/python/testing) endif() file(COPY ortools/sat/python/cp_model.py diff --git a/ortools/math_opt/model_parameters.proto b/ortools/math_opt/model_parameters.proto index 8e4601a1b7..7d9fb34700 100644 --- a/ortools/math_opt/model_parameters.proto +++ b/ortools/math_opt/model_parameters.proto @@ -146,4 +146,17 @@ message ModelSolveParametersProto { // Requirements: // * Map keys must also be map keys of ModelProto.auxiliary_objectives. map auxiliary_objective_parameters = 8; + + // 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. + // + // Requirements: + // * Each entry must be an element of VariablesProto.ids. + // * Entries must be in strictly increasing order (i.e., sorted, no repeats). + repeated int64 lazy_linear_constraint_ids = 9; } diff --git a/ortools/math_opt/python/CMakeLists.txt b/ortools/math_opt/python/CMakeLists.txt index 6e4c07671d..3a6cf506b2 100644 --- a/ortools/math_opt/python/CMakeLists.txt +++ b/ortools/math_opt/python/CMakeLists.txt @@ -13,7 +13,12 @@ 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) - #add_python_test(FILE_NAME ${FILE_NAME}) + add_python_test(FILE_NAME ${FILE_NAME}) endforeach() endif() diff --git a/ortools/math_opt/python/callback_test.py b/ortools/math_opt/python/callback_test.py index 0fb13aa088..5b47fab589 100644 --- a/ortools/math_opt/python/callback_test.py +++ b/ortools/math_opt/python/callback_test.py @@ -15,7 +15,7 @@ import datetime import math -import unittest +from absl.testing import absltest from ortools.math_opt import callback_pb2 from ortools.math_opt import sparse_containers_pb2 from ortools.math_opt.python import callback @@ -24,7 +24,7 @@ from ortools.math_opt.python import sparse_containers from ortools.math_opt.python.testing import compare_proto -class CallbackDataTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class CallbackDataTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_parse_callback_data_no_solution(self) -> None: mod = model.Model(name="test_model") cb_data_proto = callback_pb2.CallbackDataProto( @@ -85,7 +85,7 @@ class CallbackDataTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): ) -class CallbackRegistrationTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class CallbackRegistrationTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def testToProto(self) -> None: mod = model.Model(name="test_model") x = mod.add_binary_variable(name="x") @@ -121,7 +121,7 @@ class CallbackRegistrationTest(compare_proto.MathOptProtoAssertions, unittest.Te class GeneratedLinearConstraintTest( - compare_proto.MathOptProtoAssertions, unittest.TestCase + compare_proto.MathOptProtoAssertions, absltest.TestCase ): def testToProto(self) -> None: mod = model.Model(name="test_model") @@ -147,7 +147,7 @@ class GeneratedLinearConstraintTest( ) -class CallbackResultTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class CallbackResultTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def testToProto(self) -> None: mod = model.Model(name="test_model") x = mod.add_binary_variable(name="x") @@ -250,4 +250,4 @@ class CallbackResultTest(compare_proto.MathOptProtoAssertions, unittest.TestCase if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/compute_infeasible_subsystem_result_test.py b/ortools/math_opt/python/compute_infeasible_subsystem_result_test.py index e44012ba82..a578f87739 100644 --- a/ortools/math_opt/python/compute_infeasible_subsystem_result_test.py +++ b/ortools/math_opt/python/compute_infeasible_subsystem_result_test.py @@ -14,7 +14,7 @@ """Tests for compute_infeasible_subsystem_result.py.""" -import unittest +from absl.testing import absltest from ortools.math_opt import infeasible_subsystem_pb2 from ortools.math_opt.python import compute_infeasible_subsystem_result from ortools.math_opt.python import model @@ -28,7 +28,7 @@ _ComputeInfeasibleSubsystemResult = ( ) -class ModelSubsetBoundsTest(unittest.TestCase, compare_proto.MathOptProtoAssertions): +class ModelSubsetBoundsTest(absltest.TestCase, compare_proto.MathOptProtoAssertions): def test_empty(self) -> None: self.assertTrue(_ModelSubsetBounds().empty()) self.assertFalse(_ModelSubsetBounds(lower=True).empty()) @@ -60,7 +60,7 @@ class ModelSubsetBoundsTest(unittest.TestCase, compare_proto.MathOptProtoAsserti ) -class ModelSubsetTest(unittest.TestCase, compare_proto.MathOptProtoAssertions): +class ModelSubsetTest(absltest.TestCase, compare_proto.MathOptProtoAssertions): def test_empty(self) -> None: m = model.Model() x = m.add_binary_variable() @@ -167,7 +167,7 @@ class ModelSubsetTest(unittest.TestCase, compare_proto.MathOptProtoAssertions): compute_infeasible_subsystem_result.parse_model_subset(model_subset, m) -class ComputeInfeasibleSubsystemResultTest(unittest.TestCase): +class ComputeInfeasibleSubsystemResultTest(absltest.TestCase): def test_to_proto_round_trip(self) -> None: m = model.Model() x = m.add_binary_variable() @@ -197,4 +197,4 @@ class ComputeInfeasibleSubsystemResultTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/expressions_test.py b/ortools/math_opt/python/expressions_test.py index ad0b2dfdee..d8cd2770c9 100644 --- a/ortools/math_opt/python/expressions_test.py +++ b/ortools/math_opt/python/expressions_test.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest +from absl.testing import absltest from ortools.math_opt.python import expressions from ortools.math_opt.python import model @@ -22,7 +22,7 @@ def _type_check_linear_sum(x: model.LinearSum) -> None: del x # Unused. -class FastSumTest(unittest.TestCase): +class FastSumTest(absltest.TestCase): def test_variables(self) -> None: mod = model.Model() x = mod.add_binary_variable() @@ -78,7 +78,7 @@ class FastSumTest(unittest.TestCase): ) -class EvaluateExpressionTest(unittest.TestCase): +class EvaluateExpressionTest(absltest.TestCase): def test_scalar_expression(self) -> None: mod = model.Model() x = mod.add_binary_variable() @@ -108,4 +108,4 @@ class EvaluateExpressionTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/hash_model_storage_test.py b/ortools/math_opt/python/hash_model_storage_test.py index 0a0d2ac94d..5423fde0c4 100644 --- a/ortools/math_opt/python/hash_model_storage_test.py +++ b/ortools/math_opt/python/hash_model_storage_test.py @@ -14,11 +14,11 @@ """Tests for hash_model_storage that cannot be covered by model_storage_(update)_test.""" -import unittest +from absl.testing import absltest from ortools.math_opt.python import hash_model_storage -class HashModelStorageTest(unittest.TestCase): +class HashModelStorageTest(absltest.TestCase): def test_quadratic_term_storage(self): storage = hash_model_storage._QuadraticTermStorage() storage.set_coefficient(0, 1, 1.0) @@ -27,4 +27,4 @@ class HashModelStorageTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/linear_expression_test.py b/ortools/math_opt/python/linear_expression_test.py index dd5cccbe78..96fcea060c 100644 --- a/ortools/math_opt/python/linear_expression_test.py +++ b/ortools/math_opt/python/linear_expression_test.py @@ -16,8 +16,8 @@ import math from typing import Any, List, NamedTuple, Union from unittest import mock -import unittest -from google3.testing.pybase import parameterized +from absl.testing import absltest +from absl.testing import parameterized from ortools.math_opt.python import model _LINEAR_TYPES = ( @@ -36,7 +36,7 @@ _QUADRATIC_TYPES = ( ) -class BoundedExprTest(unittest.TestCase): +class BoundedExprTest(absltest.TestCase): def test_eq_float(self) -> None: mod = model.Model() x = mod.add_binary_variable(name="x") @@ -333,7 +333,7 @@ class BoundedExprTest(unittest.TestCase): self.assertEqual(bounded_expr.upper_bound, math.inf) -class BoundedExprErrorTest(unittest.TestCase): +class BoundedExprErrorTest(absltest.TestCase): def test_ne(self) -> None: mod = model.Model() x = mod.add_binary_variable(name="x") @@ -588,7 +588,7 @@ class BoundedExprErrorTest(unittest.TestCase): # pylint: enable=pointless-statement -class BoundedExprStrAndReprTest(unittest.TestCase): +class BoundedExprStrAndReprTest(absltest.TestCase): def test_upper_bounded_expr(self) -> None: mod = model.Model() x = mod.add_binary_variable(name="x") @@ -1086,7 +1086,7 @@ class LinearNumberOpTests(parameterized.TestCase): self.assertDictEqual(dict(e_inplace.terms), expected_terms) -class QuadraticTermKey(unittest.TestCase): +class QuadraticTermKey(absltest.TestCase): # Mock QuadraticTermKey.__hash__ to have a collision in the dictionary lookup # so that a correct behavior of term1 == term2 is needed to recover the # values. For instance, if QuadraticTermKey.__eq__ only compared equality of @@ -2738,7 +2738,7 @@ class AstTest(parameterized.TestCase): # Test behavior of LinearExpression and as_flat_linear_expression that is # not covered by other tests. -class LinearExpressionTest(unittest.TestCase): +class LinearExpressionTest(absltest.TestCase): def test_init_to_zero(self) -> None: expression = model.LinearExpression() self.assertEqual(expression.offset, 0.0) @@ -2774,7 +2774,7 @@ class LinearExpressionTest(unittest.TestCase): # Test behavior of QuadraticExpression and as_flat_quadratic_expression that is # not covered by other tests. -class QuadraticExpressionTest(unittest.TestCase): +class QuadraticExpressionTest(absltest.TestCase): def test_terms_read_only(self) -> None: mod = model.Model() x = mod.add_binary_variable(name="x") @@ -2809,4 +2809,4 @@ class QuadraticExpressionTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/mathopt_test.py b/ortools/math_opt/python/mathopt_test.py index b6037b9ee2..d35e28d351 100644 --- a/ortools/math_opt/python/mathopt_test.py +++ b/ortools/math_opt/python/mathopt_test.py @@ -18,7 +18,7 @@ import types import typing from typing import Any, List, Set, Tuple -import unittest +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 @@ -81,7 +81,7 @@ def _get_public_api(module: types.ModuleType) -> List[Tuple[str, Any]]: return [(name, obj) for name, obj in tuple_list if not name.startswith("_")] -class MathoptTest(unittest.TestCase): +class MathoptTest(absltest.TestCase): def test_imports(self) -> None: missing_imports: List[str] = [] for module in _MODULES_TO_CHECK: @@ -107,4 +107,4 @@ class MathoptTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/message_callback_test.py b/ortools/math_opt/python/message_callback_test.py index f9a0da1e52..7e97c947f8 100644 --- a/ortools/math_opt/python/message_callback_test.py +++ b/ortools/math_opt/python/message_callback_test.py @@ -19,11 +19,11 @@ import os from absl import logging -import unittest +from absl.testing import absltest from ortools.math_opt.python import message_callback -class PrinterMessageCallbackTest(unittest.TestCase): +class PrinterMessageCallbackTest(absltest.TestCase): def test_no_prefix(self): class FlushCountingStringIO(io.StringIO): def __init__(self): @@ -53,7 +53,7 @@ class PrinterMessageCallbackTest(unittest.TestCase): ) -class LogMessagesTest(unittest.TestCase): +class LogMessagesTest(absltest.TestCase): def test_defaults(self): with self.assertLogs(logger="absl", level="INFO") as logs: message_callback.log_messages(["line 1", "line 2"]) @@ -82,7 +82,7 @@ class LogMessagesTest(unittest.TestCase): ) -class VLogMessagesTest(unittest.TestCase): +class VLogMessagesTest(absltest.TestCase): """Tests of vlog_messages(). In the tests we abuse the logging level 0 since there is not API in the @@ -110,7 +110,7 @@ class VLogMessagesTest(unittest.TestCase): ) -class ListMessageCallbackTest(unittest.TestCase): +class ListMessageCallbackTest(absltest.TestCase): def test_empty(self): msgs = [] cb = message_callback.list_message_callback(msgs) @@ -131,4 +131,4 @@ class ListMessageCallbackTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/model_parameters_test.py b/ortools/math_opt/python/model_parameters_test.py index aa35b04a1f..f17f597ef9 100644 --- a/ortools/math_opt/python/model_parameters_test.py +++ b/ortools/math_opt/python/model_parameters_test.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest +from absl.testing import absltest from ortools.math_opt import model_parameters_pb2 from ortools.math_opt import solution_pb2 from ortools.math_opt import sparse_containers_pb2 @@ -23,7 +23,7 @@ from ortools.math_opt.python import sparse_containers from ortools.math_opt.python.testing import compare_proto -class ModelParametersTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class ModelParametersTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_solution_hint_round_trip(self) -> None: mod = model.Model(name="test_model") x = mod.add_binary_variable(name="x") @@ -106,4 +106,4 @@ class ModelParametersTest(compare_proto.MathOptProtoAssertions, unittest.TestCas if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/model_storage_test.py b/ortools/math_opt/python/model_storage_test.py index 476bedfbe2..49849f2354 100644 --- a/ortools/math_opt/python/model_storage_test.py +++ b/ortools/math_opt/python/model_storage_test.py @@ -15,8 +15,8 @@ import math from typing import Any, Callable -import unittest -from google3.testing.pybase import parameterized +from absl.testing import absltest +from absl.testing import parameterized from ortools.math_opt import model_pb2 from ortools.math_opt import sparse_containers_pb2 from ortools.math_opt.python import hash_model_storage @@ -925,4 +925,4 @@ class ModelStorageTest(compare_proto.MathOptProtoAssertions, parameterized.TestC if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/model_storage_update_test.py b/ortools/math_opt/python/model_storage_update_test.py index bb111271fe..269d3d9914 100644 --- a/ortools/math_opt/python/model_storage_update_test.py +++ b/ortools/math_opt/python/model_storage_update_test.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest -from google3.testing.pybase import parameterized +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 @@ -1171,4 +1171,4 @@ class ModelStorageTest(compare_proto.MathOptProtoAssertions, parameterized.TestC if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/model_test.py b/ortools/math_opt/python/model_test.py index b95711ba7e..59afae5410 100644 --- a/ortools/math_opt/python/model_test.py +++ b/ortools/math_opt/python/model_test.py @@ -15,8 +15,8 @@ import math from typing import Type -import unittest -from google3.testing.pybase import parameterized +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 @@ -1076,7 +1076,7 @@ class ModelTest(compare_proto.MathOptProtoAssertions, parameterized.TestCase): mod.remove_update_tracker(t1) -class WrongAttributeTest(unittest.TestCase): +class WrongAttributeTest(absltest.TestCase): """Test case that verifies that wrong attributes are detected. In some the tests below we have to disable pytype checks since it also detects @@ -1107,4 +1107,4 @@ class WrongAttributeTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/normalize_test.py b/ortools/math_opt/python/normalize_test.py index 94d8a4e2c3..95300d9dcd 100644 --- a/ortools/math_opt/python/normalize_test.py +++ b/ortools/math_opt/python/normalize_test.py @@ -15,7 +15,7 @@ """Test normalize for mathopt protos.""" from google3.net.proto2.contrib.pyutil import compare -import unittest +from absl.testing import absltest from ortools.math_opt import model_parameters_pb2 from ortools.math_opt import model_pb2 from ortools.math_opt import model_update_pb2 @@ -25,7 +25,7 @@ from ortools.math_opt import sparse_containers_pb2 from ortools.math_opt.python import normalize -class MathOptProtoAssertionsTest(unittest.TestCase, compare.Proto2Assertions): +class MathOptProtoAssertionsTest(absltest.TestCase, compare.Proto2Assertions): def test_removes_empty_message(self) -> None: model_with_empty_vars = model_pb2.ModelProto() model_with_empty_vars.variables.SetInParent() @@ -124,4 +124,4 @@ class MathOptProtoAssertionsTest(unittest.TestCase, compare.Proto2Assertions): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/parameters_test.py b/ortools/math_opt/python/parameters_test.py index 2d3a52750b..9dad699a6e 100644 --- a/ortools/math_opt/python/parameters_test.py +++ b/ortools/math_opt/python/parameters_test.py @@ -15,8 +15,8 @@ import datetime from typing import Any -import unittest -from google3.testing.pybase import parameterized +from absl.testing import absltest +from absl.testing import parameterized from ortools.pdlp import solvers_pb2 as pdlp_solvers_pb2 from ortools.glop import parameters_pb2 as glop_parameters_pb2 from ortools.gscip import gscip_pb2 @@ -30,7 +30,7 @@ from ortools.math_opt.solvers import osqp_pb2 from ortools.sat import sat_parameters_pb2 -class GurobiParameters(unittest.TestCase): +class GurobiParameters(absltest.TestCase): def test_to_proto(self) -> None: gurobi_proto = parameters.GurobiParameters( param_values={"x": "dog", "ab": "7"} @@ -44,7 +44,7 @@ class GurobiParameters(unittest.TestCase): self.assertEqual(expected_proto, gurobi_proto) -class GlpkParameters(unittest.TestCase): +class GlpkParameters(absltest.TestCase): def test_to_proto(self) -> None: # Test with `optional bool` set to true. glpk_proto = parameters.GlpkParameters( @@ -70,7 +70,7 @@ class GlpkParameters(unittest.TestCase): self.assertEqual(glpk_proto, expected_proto) -class ProtoRoundTrip(unittest.TestCase): +class ProtoRoundTrip(absltest.TestCase): def test_solver_type_round_trip(self) -> None: for solver_type in parameters.SolverType: self.assertEqual( @@ -227,4 +227,4 @@ class SolveParametersTest(compare_proto.MathOptProtoAssertions, parameterized.Te if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/result_test.py b/ortools/math_opt/python/result_test.py index 07f08bd3cf..cc35d4e1e6 100644 --- a/ortools/math_opt/python/result_test.py +++ b/ortools/math_opt/python/result_test.py @@ -15,7 +15,7 @@ import datetime import math -import unittest +from absl.testing import absltest from ortools.math_opt import result_pb2 from ortools.math_opt import solution_pb2 from ortools.math_opt import sparse_containers_pb2 @@ -25,7 +25,7 @@ from ortools.math_opt.python import solution from ortools.math_opt.python.testing import compare_proto -class ParseTerminationReason(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class ParseTerminationReason(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_termination_unspecified(self) -> None: termination_proto = result_pb2.TerminationProto( reason=result_pb2.TERMINATION_REASON_UNSPECIFIED @@ -85,7 +85,7 @@ class ParseTerminationReason(compare_proto.MathOptProtoAssertions, unittest.Test ) -class ParseProblemStatus(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class ParseProblemStatus(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_problem_status_round_trip(self) -> None: problem_status = result.ProblemStatus( primal_status=result.FeasibilityStatus.FEASIBLE, @@ -123,7 +123,7 @@ class ParseProblemStatus(compare_proto.MathOptProtoAssertions, unittest.TestCase result.parse_problem_status(proto) -class ParseObjectiveBounds(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class ParseObjectiveBounds(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_objective_bounds_round_trip(self) -> None: objective_bounds = result.ObjectiveBounds(primal_bound=10, dual_bound=20) objective_bounds_proto = objective_bounds.to_proto() @@ -135,7 +135,7 @@ class ParseObjectiveBounds(compare_proto.MathOptProtoAssertions, unittest.TestCa self.assertEqual(objective_bounds, round_trip_objective_bounds) -class ParseSolveStats(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class ParseSolveStats(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_problem_status_round_trip(self) -> None: solve_stats = result.SolveStats( solve_time=datetime.timedelta(seconds=10), @@ -156,7 +156,7 @@ class ParseSolveStats(compare_proto.MathOptProtoAssertions, unittest.TestCase): self.assertEqual(solve_stats, round_trip_solve_stats) -class SolveResultAuxiliaryFunctionsTest(unittest.TestCase): +class SolveResultAuxiliaryFunctionsTest(absltest.TestCase): def test_solve_time(self) -> None: res = result.SolveResult( solve_stats=result.SolveStats(solve_time=datetime.timedelta(seconds=10)) @@ -637,7 +637,7 @@ def _make_undetermined_result_proto() -> result_pb2.SolveResultProto: return proto -class SolveResultTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class SolveResultTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_solve_result_gscip_output(self) -> None: mod = model.Model(name="test_model") mod.add_binary_variable() @@ -1044,4 +1044,4 @@ class SolveResultTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/solution_test.py b/ortools/math_opt/python/solution_test.py index ae2558b10d..7c481e0af6 100644 --- a/ortools/math_opt/python/solution_test.py +++ b/ortools/math_opt/python/solution_test.py @@ -12,14 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest +from absl.testing import absltest from ortools.math_opt import solution_pb2 from ortools.math_opt.python import model from ortools.math_opt.python import solution from ortools.math_opt.python.testing import compare_proto -class ParsePrimalSolutionTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class ParsePrimalSolutionTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_empty_primal_solution_proto_round_trip(self) -> None: mod = model.Model(name="test_model") empty_solution = solution.PrimalSolution( @@ -63,7 +63,7 @@ class ParsePrimalSolutionTest(compare_proto.MathOptProtoAssertions, unittest.Tes solution.parse_primal_solution(proto, mod) -class ParsePrimalRayTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class ParsePrimalRayTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_parse(self) -> None: mod = model.Model(name="test_model") x = mod.add_binary_variable(name="x") @@ -75,7 +75,7 @@ class ParsePrimalRayTest(compare_proto.MathOptProtoAssertions, unittest.TestCase self.assertDictEqual({x: 1.0, y: 1.0}, actual.variable_values) -class ParseDualSolutionTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class ParseDualSolutionTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_empty_primal_solution_proto_round_trip(self) -> None: mod = model.Model(name="test_model") empty_solution = solution.DualSolution( @@ -136,7 +136,7 @@ class ParseDualSolutionTest(compare_proto.MathOptProtoAssertions, unittest.TestC solution.parse_dual_solution(proto, mod) -class ParseDualRayTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class ParseDualRayTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_parse(self) -> None: mod = model.Model(name="test_model") x = mod.add_binary_variable(name="x") @@ -153,7 +153,7 @@ class ParseDualRayTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): self.assertDictEqual({c: 0.0, d: 1.0}, actual.dual_values) -class BasisTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class BasisTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_empty_basis_proto_round_trip(self) -> None: mod = model.Model(name="test_model") empty_basis = solution.Basis() @@ -267,7 +267,7 @@ class BasisTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): solution.parse_basis(basis_proto, mod) -class ParseSolutionTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class ParseSolutionTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_solution_proto_round_trip(self) -> None: mod = model.Model(name="test_model") mod.add_variable() @@ -310,4 +310,4 @@ class ParseSolutionTest(compare_proto.MathOptProtoAssertions, unittest.TestCase) if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/solve_gurobi_test.py b/ortools/math_opt/python/solve_gurobi_test.py index 23c4306993..afa1703306 100644 --- a/ortools/math_opt/python/solve_gurobi_test.py +++ b/ortools/math_opt/python/solve_gurobi_test.py @@ -18,7 +18,7 @@ These tests are in a separate file because Gurobi can only run on a licensed machine. """ -import unittest +from absl.testing import absltest from ortools.math_opt.python import callback from ortools.math_opt.python import compute_infeasible_subsystem_result from ortools.math_opt.python import model @@ -30,7 +30,7 @@ from ortools.math_opt.python import solve _Bounds = compute_infeasible_subsystem_result.ModelSubsetBounds -class SolveTest(unittest.TestCase): +class SolveTest(absltest.TestCase): def test_callback(self) -> None: mod = model.Model(name="test_model") # Solve the problem: @@ -90,4 +90,4 @@ class SolveTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/solve_test.py b/ortools/math_opt/python/solve_test.py index 460928717a..ede02dbf81 100644 --- a/ortools/math_opt/python/solve_test.py +++ b/ortools/math_opt/python/solve_test.py @@ -15,7 +15,7 @@ import io from typing import Dict, List, Union -import unittest +from absl.testing import absltest from ortools.math_opt.core.python import solver as core_solver from ortools.math_opt.python import message_callback from ortools.math_opt.python import model @@ -45,7 +45,7 @@ def _list_is_near(v1: List[float], v2: List[float], tolerance: float = 1e-5) -> return True -class SolveTest(unittest.TestCase): +class SolveTest(absltest.TestCase): def _assert_dict_almost_equal( self, expected: VarOrConstraintDict, actual: VarOrConstraintDict, places=5 ): @@ -555,4 +555,4 @@ class SolveTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/sparse_containers_test.py b/ortools/math_opt/python/sparse_containers_test.py index 84b775f0e7..55b1fb492f 100644 --- a/ortools/math_opt/python/sparse_containers_test.py +++ b/ortools/math_opt/python/sparse_containers_test.py @@ -12,14 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest +from absl.testing import absltest from ortools.math_opt import sparse_containers_pb2 from ortools.math_opt.python import model from ortools.math_opt.python import sparse_containers from ortools.math_opt.python.testing import compare_proto -class SparseDoubleVectorTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class SparseDoubleVectorTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_to_proto_empty(self) -> None: actual = sparse_containers.to_sparse_double_vector_proto({}) self.assert_protos_equiv( @@ -96,7 +96,7 @@ class SparseDoubleVectorTest(compare_proto.MathOptProtoAssertions, unittest.Test self.assertDictEqual(actual, {}) -class SparseInt32VectorTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class SparseInt32VectorTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_to_proto_empty(self) -> None: self.assert_protos_equiv( sparse_containers.to_sparse_int32_vector_proto({}), @@ -123,7 +123,7 @@ class SparseInt32VectorTest(compare_proto.MathOptProtoAssertions, unittest.TestC ) -class SparseVectorFilterTest(compare_proto.MathOptProtoAssertions, unittest.TestCase): +class SparseVectorFilterTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_is_none(self) -> None: f = sparse_containers.SparseVectorFilter(skip_zero_values=True) self.assertTrue(f.skip_zero_values) @@ -172,4 +172,4 @@ class SparseVectorFilterTest(compare_proto.MathOptProtoAssertions, unittest.Test if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/statistics_test.py b/ortools/math_opt/python/statistics_test.py index 1fb3361cbc..b2fc6ca8ff 100644 --- a/ortools/math_opt/python/statistics_test.py +++ b/ortools/math_opt/python/statistics_test.py @@ -16,12 +16,12 @@ import math -import unittest +from absl.testing import absltest from ortools.math_opt.python import model from ortools.math_opt.python import statistics -class RangeTest(unittest.TestCase): +class RangeTest(absltest.TestCase): def test_merge_optional_ranges(self) -> None: self.assertIsNone(statistics.merge_optional_ranges(None, None)) r = statistics.Range(1.0, 3.0) @@ -51,7 +51,7 @@ class RangeTest(unittest.TestCase): ) -class ModelRangesTest(unittest.TestCase): +class ModelRangesTest(absltest.TestCase): def test_printing(self) -> None: self.assertMultiLineEqual( str( @@ -129,7 +129,7 @@ class ModelRangesTest(unittest.TestCase): ) -class ComputeModelRangesTest(unittest.TestCase): +class ComputeModelRangesTest(absltest.TestCase): def test_empty(self) -> None: mdl = model.Model(name="model") self.assertEqual( @@ -194,4 +194,4 @@ class ComputeModelRangesTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() + absltest.main() diff --git a/ortools/math_opt/python/testing/compare_proto.py b/ortools/math_opt/python/testing/compare_proto.py new file mode 100644 index 0000000000..1ae27b04b4 --- /dev/null +++ b/ortools/math_opt/python/testing/compare_proto.py @@ -0,0 +1,55 @@ +# Copyright 2010-2024 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Assertions to test that protos are equivalent in MathOpt's sense. + +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 +selection. This is similar to C++ EquivToProto, but this function cares about: + * field presence for optional scalar fields, + * field presence for Duration messages (e.g. Duration unset of time limit + means +inf), + * field presence for one_ofs, +and C++ EquivToProto does not. + +See normalize.py for details. +""" + +import copy +import unittest + +from google.protobuf import message + +from ortools.math_opt.python import normalize + + +def assert_protos_equiv( + 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) + normalize.math_opt_normalize_proto(normalized_actual) + normalized_expected = copy.deepcopy(expected) + normalize.math_opt_normalize_proto(normalized_expected) + test.assertEqual(str(normalized_actual), str(normalized_expected)) + + +class MathOptProtoAssertions(unittest.TestCase): + """Provides a custom MathOpt proto equivalence assertion for tests.""" + + def assert_protos_equiv( + 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) diff --git a/ortools/math_opt/python/testing/proto_matcher.py b/ortools/math_opt/python/testing/proto_matcher.py new file mode 100644 index 0000000000..f314a4f03b --- /dev/null +++ b/ortools/math_opt/python/testing/proto_matcher.py @@ -0,0 +1,48 @@ +# Copyright 2010-2024 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A matcher that tests if protos meet MathOpts definition of equivalence. + +This is designed to be used with unittest.mock, which is canonical for mocking +in Google Python (e.g., see stubby codelabs). + +See normalize.py for technical definitions. + +The matcher can be used as a replacement for +google3.net.proto2.contrib.pyutil.matcher.Proto2Matcher +but supports a much smaller set of features. +""" + +import copy + +from google.protobuf import message +from ortools.math_opt.python import normalize + + +class MathOptProtoEquivMatcher: + """Matcher that checks if protos are equivalent in the MathOpt sense. + + See normalize.py for technical definitions. + """ + + def __init__(self, expected: message.Message): + self._expected = copy.deepcopy(expected) + normalize.math_opt_normalize_proto(self._expected) + + def __eq__(self, actual: message.Message) -> bool: + actual = copy.deepcopy(actual) + normalize.math_opt_normalize_proto(actual) + return str(actual) == str(self._expected) + + def __ne__(self, other: message.Message) -> bool: + return not self == other diff --git a/ortools/math_opt/rpc.proto b/ortools/math_opt/rpc.proto index 36d054bea0..415efa93cf 100644 --- a/ortools/math_opt/rpc.proto +++ b/ortools/math_opt/rpc.proto @@ -26,6 +26,43 @@ import "ortools/math_opt/result.proto"; option java_package = "com.google.ortools.mathopt"; option java_multiple_files = true; +// This message is used to specify some hints on the resources a remote solve is +// expected to use. These parameters are hints and may be ignored by the remote +// server (in particular in case of solve in a local subprocess for example). +// +// When using SolveService.Solve and SolveService.ComputeInfeasibleSubsystem, +// these hints are mostly optional as some defaults will be computed based on +// the other parameters. +// +// When using SolveService.StreamSolve these hints are used to dimension the +// resources available during the execution of every action; thus it is +// recommended to set them. +// +message SolverResourcesProto { + // The number of solver threads that are expected to actually execute in + // parallel. Must be finite and >0.0. + // + // For example a value of 3.0 means that if the solver has 5 threads that can + // execute we expect at least 3 of these threads to be schedule in parallel + // for any given time slice of the operating system scheduler. + // + // A fractional value indicate that we don't expect the operating system to + // constantly schedule the solver's work. For example with 0.5 we would expect + // the solver's threads to be scheduled half the time. + // + // This parameter is usually used in conjunction with + // SolveParametersProto.threads. For some solvers like Gurobi it makes sense + // to use SolverResourcesProto.cpu = SolveParametersProto.threads. For other + // solvers like CP-SAT, it may makes sense to use a value lower than the + // number of threads as not all threads may be ready to be scheduled at the + // same time. It is better to consult each solver documentation to set this + // parameter. + // + // Note that if the SolveParametersProto.threads is not set then this + // parameter should also be left unset. + optional double cpu = 1; +} + // Request for a unary remote solve in MathOpt. message SolveRequest { // Solver type to numerically solve the problem. Note that if a solver does @@ -36,6 +73,9 @@ message SolveRequest { // A mathematical representation of the optimization problem to solve. ModelProto model = 2; + // Hints on resources requested for the solve. + SolverResourcesProto resources = 6; + SolverInitializerProto initializer = 3; // Parameters to control a single solve. The enable_output parameter is diff --git a/ortools/math_opt/validators/BUILD.bazel b/ortools/math_opt/validators/BUILD.bazel index 583a4f1fc0..fd454b3810 100644 --- a/ortools/math_opt/validators/BUILD.bazel +++ b/ortools/math_opt/validators/BUILD.bazel @@ -11,7 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -package(default_visibility = ["//ortools/math_opt:__subpackages__"]) +package(default_visibility = ["//ortools:__subpackages__"]) cc_library( name = "ids_validator", @@ -200,6 +200,7 @@ cc_library( "//ortools/math_opt/core:model_summary", "//ortools/math_opt/core:sparse_vector_view", "@com_google_absl//absl/status", + "@com_google_protobuf//:protobuf", ], ) diff --git a/ortools/math_opt/validators/model_parameters_validator.cc b/ortools/math_opt/validators/model_parameters_validator.cc index 9fc4c2e811..1559f84335 100644 --- a/ortools/math_opt/validators/model_parameters_validator.cc +++ b/ortools/math_opt/validators/model_parameters_validator.cc @@ -13,7 +13,10 @@ #include "ortools/math_opt/validators/model_parameters_validator.h" +#include + #include "absl/status/status.h" +#include "google/protobuf/repeated_field.h" #include "ortools/base/status_builder.h" #include "ortools/base/status_macros.h" #include "ortools/math_opt/core/model_summary.h" @@ -80,6 +83,17 @@ absl::Status ValidateObjectiveParameters( return absl::OkStatus(); } +absl::Status ValidateLazyLinearConstraints( + const google::protobuf::RepeatedField& lazy_linear_constraint_ids, + const ModelSummary& model_summary) { + RETURN_IF_ERROR( + CheckIdsRangeAndStrictlyIncreasing(lazy_linear_constraint_ids)); + RETURN_IF_ERROR(CheckIdsSubset( + lazy_linear_constraint_ids, model_summary.linear_constraints, + "lazy_linear_constraint ids", "model linear constraint IDs")); + return absl::OkStatus(); +} + } // namespace absl::Status ValidateSparseVectorFilter(const SparseVectorFilterProto& v, @@ -133,6 +147,9 @@ absl::Status ValidateModelSolveParameters( << "invalid auxiliary_objective_parameters entry for objective: " << objective; } + RETURN_IF_ERROR(ValidateLazyLinearConstraints( + parameters.lazy_linear_constraint_ids(), model_summary)) + << "invalid lazy_linear_constraint_ids"; return absl::OkStatus(); }