#!/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 mathopt elemental python bindings.""" import math import numpy as np from absl.testing import absltest from ortools.math_opt import model_pb2 from ortools.math_opt import model_update_pb2 from ortools.math_opt.elemental.python import cpp_elemental from ortools.math_opt.elemental.python import enums from ortools.math_opt.python.testing import compare_proto _VARIABLE = enums.ElementType.VARIABLE _LINEAR_CONSTRAINT = enums.ElementType.LINEAR_CONSTRAINT _INDICATOR_CONSTRAINT = enums.ElementType.INDICATOR_CONSTRAINT _MAXIMIZE = enums.BoolAttr0.MAXIMIZE _VARIABLE_LOWER_BOUND = enums.DoubleAttr1.VARIABLE_LOWER_BOUND _LINEAR_CONSTRAINT_COEFFICIENT = enums.DoubleAttr2.LINEAR_CONSTRAINT_COEFFICIENT _OBJECTIVE_QUADRATIC_COEFFICIENT = ( enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT ) _INDICATOR_CONSTRAINT_INDICATOR = enums.VariableAttr1.INDICATOR_CONSTRAINT_INDICATOR def _sort_attr_keys( attr_keys: np.typing.NDArray[np.int64], ) -> np.typing.NDArray[np.int64]: """Sorts attr_keys lexicographically.""" return attr_keys[np.lexsort(np.rot90(attr_keys))] class BindingsTest(compare_proto.MathOptProtoAssertions, absltest.TestCase): def test_init_names_not_set(self): e = cpp_elemental.CppElemental() self.assertEqual(e.model_name, "") self.assertEqual(e.primary_objective_name, "") def test_init_names_set(self): e = cpp_elemental.CppElemental(model_name="abc", primary_objective_name="123") self.assertEqual(e.model_name, "abc") self.assertEqual(e.primary_objective_name, "123") def test_element_operations(self): e = cpp_elemental.CppElemental() # Add two variables. xs = e.add_elements(_VARIABLE, 2) self.assertEqual(e.get_num_elements(_VARIABLE), 2) self.assertEqual(e.get_next_element_id(_VARIABLE), xs[-1] + 1) np.testing.assert_array_equal( e.elements_exist(_VARIABLE, xs), [True, True], strict=True ) # Delete first variable. e.delete_elements(_VARIABLE, xs[0:1]) np.testing.assert_array_equal( e.elements_exist(_VARIABLE, xs), [False, True], strict=True ) # Add constraint c. c = e.add_element(_LINEAR_CONSTRAINT, "c") self.assertEqual(e.get_num_elements(_LINEAR_CONSTRAINT), 1) self.assertEqual(e.element_exists(_LINEAR_CONSTRAINT, c), True) self.assertEqual(e.get_element_name(_LINEAR_CONSTRAINT, c), "c") np.testing.assert_array_equal(e.get_elements(_VARIABLE), [xs[1]], strict=True) np.testing.assert_array_equal( e.get_elements(_LINEAR_CONSTRAINT), np.array([c], dtype=np.int64), strict=True, ) def test_ensure_next_element_id_at_least(self): e = cpp_elemental.CppElemental() e.ensure_next_element_id_at_least(_VARIABLE, 4) self.assertEqual(e.add_element(_VARIABLE, "x"), 4) def test_name_handling(self): e = cpp_elemental.CppElemental() ids = e.add_named_elements( _LINEAR_CONSTRAINT, np.array(["c", "name", "a somewhat long name", "a 💩 name"]), ) np.testing.assert_array_equal( e.get_element_names(_LINEAR_CONSTRAINT, ids), np.array(["c", "name", "a somewhat long name", "a 💩 name"]), strict=True, ) with self.assertRaisesRegex(ValueError, "got 1d array of dtype l"): e.add_named_elements(_LINEAR_CONSTRAINT, np.array([1, 2, 3])) with self.assertRaisesRegex(ValueError, "got 2d array of dtype U"): e.add_named_elements(_LINEAR_CONSTRAINT, np.array([["a", "b"], ["c", "d"]])) def test_delete_with_duplicates_raises(self): e = cpp_elemental.CppElemental() xs = e.add_elements(_VARIABLE, 1) with self.assertRaisesRegex(ValueError, "duplicates"): e.delete_elements(_VARIABLE, np.array([xs[0], xs[0]])) def test_element_operations_bad_shape(self): e = cpp_elemental.CppElemental() ids = e.add_elements(_VARIABLE, 2) with self.assertRaisesRegex( ValueError, "array has incorrect number of dimensions: 2; expected 1" ): e.delete_elements(_VARIABLE, np.full((1, 1), ids[0])) def test_bad_element_type_raises(self): e = cpp_elemental.CppElemental() with self.assertRaisesRegex(TypeError, "incompatible function arguments"): e.add_elements(-42, 1) def test_attr0(self): e = cpp_elemental.CppElemental() keys = np.empty((1, 0), np.int64) default_value = e.get_attrs(_MAXIMIZE, keys) self.assertFalse(e.is_attr_non_default(_MAXIMIZE, keys[0])) self.assertEqual(e.get_attr_num_non_defaults(_MAXIMIZE), 0) np.testing.assert_array_equal( e.get_attr_non_defaults(_MAXIMIZE), np.empty((0, 0), np.int64), strict=True, ) new_value = np.invert(default_value) e.set_attrs(_MAXIMIZE, keys, new_value) self.assertEqual(e.get_attrs(_MAXIMIZE, keys), new_value) np.testing.assert_array_equal( e.bulk_is_attr_non_default(_MAXIMIZE, keys), np.array([True]), strict=True, ) self.assertEqual(e.get_attr_num_non_defaults(_MAXIMIZE), 1) np.testing.assert_array_equal( e.get_attr_non_defaults(_MAXIMIZE), keys, strict=True ) def test_attr1(self): e = cpp_elemental.CppElemental() x = e.add_elements(_VARIABLE, 3) keys = np.column_stack([x]) np.testing.assert_array_equal( e.get_attrs(_VARIABLE_LOWER_BOUND, keys), np.array([-np.inf, -np.inf, -np.inf]), ) self.assertEqual(e.get_attr(_VARIABLE_LOWER_BOUND, keys[0]), -math.inf) np.testing.assert_array_equal( e.bulk_is_attr_non_default(_VARIABLE_LOWER_BOUND, keys), np.array([False, False, False]), strict=True, ) self.assertEqual(e.is_attr_non_default(_VARIABLE_LOWER_BOUND, keys[0]), False) np.testing.assert_array_equal( e.get_attr_non_defaults(_VARIABLE_LOWER_BOUND), np.empty((0, 1), np.int64), strict=True, ) e.set_attrs( _VARIABLE_LOWER_BOUND, keys[[0, 2]], np.array([42.0, 44.0]), ) np.testing.assert_array_equal( e.get_attrs(_VARIABLE_LOWER_BOUND, keys), np.array([42.0, -np.inf, 44.0]), strict=True, ) e.set_attr(_VARIABLE_LOWER_BOUND, keys[0], 45.0) np.testing.assert_array_equal( e.get_attrs(_VARIABLE_LOWER_BOUND, keys), np.array([45.0, -np.inf, 44.0]), strict=True, ) np.testing.assert_array_equal( e.bulk_is_attr_non_default(_VARIABLE_LOWER_BOUND, keys), np.array([True, False, True]), strict=True, ) self.assertEqual(e.get_attr_num_non_defaults(_VARIABLE_LOWER_BOUND), 2) # Note: sorting the result because ordering is not guaranteed. np.testing.assert_array_equal( np.sort(e.get_attr_non_defaults(_VARIABLE_LOWER_BOUND), axis=0), np.array([[x[0]], [x[2]]]), strict=True, ) def test_attr2(self): e = cpp_elemental.CppElemental() x = e.add_elements(_VARIABLE, 1) c = e.add_elements(_LINEAR_CONSTRAINT, 1) keys = np.column_stack([x, c]) np.testing.assert_array_equal( e.get_attrs(_LINEAR_CONSTRAINT_COEFFICIENT, keys), np.array([0.0]), strict=True, ) np.testing.assert_array_equal( e.bulk_is_attr_non_default(_LINEAR_CONSTRAINT_COEFFICIENT, keys), np.array([False]), strict=True, ) self.assertEqual(e.get_attr_num_non_defaults(_LINEAR_CONSTRAINT_COEFFICIENT), 0) np.testing.assert_array_equal( e.get_attr_non_defaults(_LINEAR_CONSTRAINT_COEFFICIENT), np.empty((0, 2), np.int64), strict=True, ) e.set_attrs(_LINEAR_CONSTRAINT_COEFFICIENT, keys, np.array([42.0])) np.testing.assert_array_equal( e.get_attrs(_LINEAR_CONSTRAINT_COEFFICIENT, keys), np.array([42.0]), strict=True, ) np.testing.assert_array_equal( e.bulk_is_attr_non_default(_LINEAR_CONSTRAINT_COEFFICIENT, keys), np.array([True]), strict=True, ) self.assertEqual(e.get_attr_num_non_defaults(_LINEAR_CONSTRAINT_COEFFICIENT), 1) np.testing.assert_array_equal( e.get_attr_non_defaults(_LINEAR_CONSTRAINT_COEFFICIENT), keys, strict=True, ) def test_attr2_symmetric(self): e = cpp_elemental.CppElemental() xs = e.add_elements(_VARIABLE, 3) q01 = [xs[0], xs[1]] q21 = [xs[2], xs[1]] q12 = [xs[1], xs[2]] e.set_attr(_OBJECTIVE_QUADRATIC_COEFFICIENT, q01, 42.0) e.set_attr(_OBJECTIVE_QUADRATIC_COEFFICIENT, q21, 43.0) e.set_attr(_OBJECTIVE_QUADRATIC_COEFFICIENT, q12, 44.0) self.assertEqual( e.get_attr_num_non_defaults(_OBJECTIVE_QUADRATIC_COEFFICIENT), 2 ) # Note: sorting the result because ordering is not guaranteed. np.testing.assert_array_equal( np.sort(e.get_attr_non_defaults(_OBJECTIVE_QUADRATIC_COEFFICIENT), axis=0), np.array([q01, q12]), strict=True, ) def test_attr1_element_valued(self): e = cpp_elemental.CppElemental() x = e.add_element(_VARIABLE, "x") ic = e.add_element(_INDICATOR_CONSTRAINT, "ic") e.set_attr(_INDICATOR_CONSTRAINT_INDICATOR, [ic], x) self.assertEqual( e.get_attr_num_non_defaults(_INDICATOR_CONSTRAINT_INDICATOR), 1 ) def test_clear_attr0(self): e = cpp_elemental.CppElemental() e.set_attr(_MAXIMIZE, (), True) self.assertTrue(e.get_attr(_MAXIMIZE, ())) e.clear_attr(_MAXIMIZE) self.assertFalse(e.get_attr(_MAXIMIZE, ())) def test_clear_attr1(self): e = cpp_elemental.CppElemental() x = e.add_element(_VARIABLE, "x") e.set_attr(_VARIABLE_LOWER_BOUND, (x,), 4.0) self.assertEqual(e.get_attr(_VARIABLE_LOWER_BOUND, (x,)), 4.0) e.clear_attr(_VARIABLE_LOWER_BOUND) self.assertEqual(e.get_attr(_VARIABLE_LOWER_BOUND, (x,)), -math.inf) def test_attr0_bad_attr_id_raises(self): e = cpp_elemental.CppElemental() with self.assertRaisesRegex(TypeError, "incompatible function arguments"): e.get_attrs(-42, np.array([1])) # Note: `assertRaisesRegex` does not seem to work with multiline regexps. with self.assertRaisesRegex(TypeError, "incompatible function arguments"): e.get_attrs(_VARIABLE, ()) with self.assertRaisesRegex(TypeError, "attr: BoolAttr0"): e.get_attrs(_VARIABLE, ()) with self.assertRaisesRegex(TypeError, "attr: DoubleAttr1"): e.get_attrs(_VARIABLE, ()) def test_attr1_bad_element_id_raises(self): e = cpp_elemental.CppElemental() with self.assertRaisesRegex(ValueError, "-1.*variable"): e.get_attrs(_VARIABLE_LOWER_BOUND, np.array([[-1]])) def test_set_attr_with_duplicates_raises(self): e = cpp_elemental.CppElemental() x = e.add_elements(_VARIABLE, 2) with self.assertRaisesRegex(ValueError, "array has duplicates"): e.set_attrs( _VARIABLE_LOWER_BOUND, np.array([[x[0]], [x[1]], [x[1]]]), np.array([42.0, 44.0, 46.0]), ) # We should not have modified any attribute. self.assertEqual(e.get_attr_num_non_defaults(_VARIABLE_LOWER_BOUND), 0) def test_set_attr_with_nonexistent_raises(self): e = cpp_elemental.CppElemental() x = e.add_elements(_VARIABLE, 1) with self.assertRaisesRegex( ValueError, "linear_constraint id 0 does not exist" ): e.set_attrs( _LINEAR_CONSTRAINT_COEFFICIENT, np.array([[x[0], -1]]), np.array([42.0]), ) def test_slice_attr1_success(self): e = cpp_elemental.CppElemental() x = e.add_element(_VARIABLE, "x") y = e.add_element(_VARIABLE, "y") e.set_attr(_VARIABLE_LOWER_BOUND, (x,), 2.0) np.testing.assert_array_equal( e.slice_attr(_VARIABLE_LOWER_BOUND, 0, x), np.array([[x]], dtype=np.int64), strict=True, ) self.assertEqual(e.get_attr_slice_size(_VARIABLE_LOWER_BOUND, 0, x), 1) np.testing.assert_array_equal( e.slice_attr(_VARIABLE_LOWER_BOUND, 0, y), np.zeros((0, 1), dtype=np.int64), strict=True, ) self.assertEqual(e.get_attr_slice_size(_VARIABLE_LOWER_BOUND, 0, y), 0) def test_slice_attr1_invalid_key_index(self): e = cpp_elemental.CppElemental() x = e.add_element(_VARIABLE, "x") with self.assertRaisesRegex(ValueError, "key_index was: -1"): e.slice_attr(_VARIABLE_LOWER_BOUND, -1, x) with self.assertRaisesRegex(ValueError, "key_index was: 1"): e.slice_attr(_VARIABLE_LOWER_BOUND, 1, x) def test_slice_attr1_invalid_element_index(self): e = cpp_elemental.CppElemental() e.add_element(_VARIABLE, "x") with self.assertRaisesRegex(ValueError, "no element with id -1"): e.slice_attr(_VARIABLE_LOWER_BOUND, 0, -1) with self.assertRaisesRegex(ValueError, "no element with id 4"): e.slice_attr(_VARIABLE_LOWER_BOUND, 0, 4) def test_slice_attr2_success(self): e = cpp_elemental.CppElemental() # The first two variables are unused so that all variable and constraint # indices are all different. e.add_element(_VARIABLE, "") e.add_element(_VARIABLE, "") x = e.add_element(_VARIABLE, "x") y = e.add_element(_VARIABLE, "y") z = e.add_element(_VARIABLE, "z") c = e.add_element(_LINEAR_CONSTRAINT, "c") d = e.add_element(_LINEAR_CONSTRAINT, "d") e.set_attr(_LINEAR_CONSTRAINT_COEFFICIENT, (c, x), 2.0) e.set_attr(_LINEAR_CONSTRAINT_COEFFICIENT, (d, x), 3.0) e.set_attr(_LINEAR_CONSTRAINT_COEFFICIENT, (d, y), 4.0) np.testing.assert_array_equal( e.slice_attr(_LINEAR_CONSTRAINT_COEFFICIENT, 0, c), np.array([[c, x]], dtype=np.int64), strict=True, ) self.assertEqual(e.get_attr_slice_size(_LINEAR_CONSTRAINT_COEFFICIENT, 0, c), 1) np.testing.assert_array_equal( _sort_attr_keys(e.slice_attr(_LINEAR_CONSTRAINT_COEFFICIENT, 0, d)), np.array([[d, x], [d, y]], dtype=np.int64), strict=True, ) self.assertEqual(e.get_attr_slice_size(_LINEAR_CONSTRAINT_COEFFICIENT, 0, d), 2) np.testing.assert_array_equal( _sort_attr_keys(e.slice_attr(_LINEAR_CONSTRAINT_COEFFICIENT, 1, x)), np.array([[c, x], [d, x]], dtype=np.int64), strict=True, ) self.assertEqual(e.get_attr_slice_size(_LINEAR_CONSTRAINT_COEFFICIENT, 1, x), 2) np.testing.assert_array_equal( e.slice_attr(_LINEAR_CONSTRAINT_COEFFICIENT, 1, y), np.array([[d, y]], dtype=np.int64), strict=True, ) self.assertEqual(e.get_attr_slice_size(_LINEAR_CONSTRAINT_COEFFICIENT, 1, y), 1) np.testing.assert_array_equal( e.slice_attr(_LINEAR_CONSTRAINT_COEFFICIENT, 1, z), np.zeros((0, 2), dtype=np.int64), strict=True, ) self.assertEqual(e.get_attr_slice_size(_LINEAR_CONSTRAINT_COEFFICIENT, 1, z), 0) def test_clone(self): e = cpp_elemental.CppElemental(model_name="mmm") x = e.add_element(_VARIABLE, "x") e.set_attr(_VARIABLE_LOWER_BOUND, (x,), 4.0) e2 = e.clone() self.assertEqual(e2.model_name, "mmm") np.testing.assert_array_equal( e2.get_elements(_VARIABLE), np.array([x], dtype=np.int64), strict=True ) np.testing.assert_array_equal( e2.get_attr_non_defaults(_VARIABLE_LOWER_BOUND), np.array([[x]], dtype=np.int64), strict=True, ) self.assertEqual(e2.get_attr(_VARIABLE_LOWER_BOUND, (x,)), 4.0) def test_clone_with_rename(self): e = cpp_elemental.CppElemental(model_name="mmm") x = e.add_element(_VARIABLE, "x") e2 = e.clone(new_model_name="yyy") self.assertEqual(e2.model_name, "yyy") np.testing.assert_array_equal( e2.get_elements(_VARIABLE), np.array([x], dtype=np.int64), strict=True ) def test_export_model(self): e = cpp_elemental.CppElemental() x = e.add_element(_VARIABLE, "x") e.set_attr(_VARIABLE_LOWER_BOUND, (x,), 4.0) expected = model_pb2.ModelProto( variables=model_pb2.VariablesProto( ids=[0], lower_bounds=[4.0], upper_bounds=[math.inf], integers=[False], names=["x"], ) ) self.assert_protos_equal(e.export_model(), expected) expected.variables.names[:] = [] self.assert_protos_equal(e.export_model(remove_names=True), expected) def test_from_model_proto(self): proto = model_pb2.ModelProto( name="model", variables=model_pb2.VariablesProto( ids=[2], lower_bounds=[4.0], upper_bounds=[math.inf], integers=[False], names=["x"], ), ) e = cpp_elemental.CppElemental.from_model_proto(proto) self.assertEqual(e.model_name, "model") x = 2 np.testing.assert_array_equal( e.get_elements(_VARIABLE), np.array([x], dtype=np.int64), strict=True ) self.assertEqual(e.get_element_name(_VARIABLE, x), "x") self.assertEqual(e.get_attr(_VARIABLE_LOWER_BOUND, (x,)), 4.0) self.assertEqual(e.get_next_element_id(_VARIABLE), 3) self.assert_protos_equal(e.export_model(), proto) def test_from_model_proto_empty(self): proto = model_pb2.ModelProto() e = cpp_elemental.CppElemental.from_model_proto(proto) self.assertEqual(e.model_name, "") self.assertEqual(e.primary_objective_name, "") for element_type in enums.ElementType: self.assertEqual(e.get_num_elements(element_type), 0) def test_repr(self): e = cpp_elemental.CppElemental() e.add_element(_VARIABLE, "xyz") self.assertEqual( repr(e), """Model: ElementType: variable num_elements: 1 next_id: 1 id: 0 name: "xyz\"""", ) def test_add_and_delete_diffs(self): e = cpp_elemental.CppElemental() self.assertEqual(e.add_diff(), 0) self.assertEqual(e.add_diff(), 1) e.delete_diff(1) def test_export_model_update_has_update(self): e = cpp_elemental.CppElemental() d = e.add_diff() e.add_element(_VARIABLE, "xyz") update = e.export_model_update(d) self.assertIsNotNone(update) expected = model_update_pb2.ModelUpdateProto( new_variables=model_pb2.VariablesProto( ids=[0], lower_bounds=[-math.inf], upper_bounds=[math.inf], integers=[False], names=["xyz"], ) ) self.assert_protos_equal(update, expected) # Now export again without names update_no_names = e.export_model_update(d, remove_names=True) self.assertIsNotNone(update_no_names) expected.new_variables.names[:] = [] self.assert_protos_equal(update_no_names, expected) def test_export_model_update_empty(self): e = cpp_elemental.CppElemental() d = e.add_diff() update = e.export_model_update(d) self.assertIsNone(update) def test_advance_diff(self): e = cpp_elemental.CppElemental() d = e.add_diff() e.add_element(_VARIABLE, "xyz") e.advance_diff(d) update = e.export_model_update(d) self.assertIsNone(update) def test_delete_diff_twice_error(self): e = cpp_elemental.CppElemental() self.assertEqual(e.add_diff(), 0) e.delete_diff(0) with self.assertRaisesRegex(ValueError, "no diff with id: 0"): e.delete_diff(0) def test_delete_diff_never_created_error(self): e = cpp_elemental.CppElemental() with self.assertRaisesRegex(ValueError, "no diff with id: 0"): e.delete_diff(0) def test_export_model_update_diff_never_created(self): e = cpp_elemental.CppElemental() with self.assertRaisesRegex(ValueError, "no diff with id: 0"): e.export_model_update(0) def test_advance_diff_never_created(self): e = cpp_elemental.CppElemental() with self.assertRaisesRegex(ValueError, "no diff with id: 0"): e.advance_diff(0) if __name__ == "__main__": absltest.main()