|
3 | 3 | tests of data types, errors, etc, are done on the lower-level functions.
|
4 | 4 | """
|
5 | 5 |
|
| 6 | +import math |
| 7 | +import unittest |
| 8 | + |
| 9 | +import gurobipy as gp |
6 | 10 | import pandas as pd
|
7 | 11 | from gurobipy import GRB
|
8 | 12 | from pandas.testing import assert_index_equal, assert_series_equal
|
9 | 13 |
|
10 | 14 | import gurobipy_pandas as gppd
|
11 | 15 | from tests.utils import GurobiModelTestCase
|
12 | 16 |
|
| 17 | +GUROBIPY_MAJOR_VERSION, *_ = gp.gurobi.version() |
| 18 | + |
13 | 19 |
|
14 | 20 | class TestAddVars(GurobiModelTestCase):
|
15 | 21 | def test_from_dataframe(self):
|
@@ -269,6 +275,136 @@ def test_sense_series(self):
|
269 | 275 | self.assertEqual(constr.RHS, -1.0)
|
270 | 276 |
|
271 | 277 |
|
| 278 | +@unittest.skipIf( |
| 279 | + GUROBIPY_MAJOR_VERSION < 12, |
| 280 | + "Nonlinear constraints are only supported for Gurobi 12 and later", |
| 281 | +) |
| 282 | +class TestNonlinear(GurobiModelTestCase): |
| 283 | + def assert_approx_equal(self, value, expected, tolerance=1e-6): |
| 284 | + difference = abs(value - expected) |
| 285 | + self.assertLessEqual(difference, tolerance) |
| 286 | + |
| 287 | + def test_log(self): |
| 288 | + # max sum y_i |
| 289 | + # s.t. y_i = log(x_i) |
| 290 | + # 1.0 <= x <= 2.0 |
| 291 | + from gurobipy import nlfunc |
| 292 | + |
| 293 | + index = pd.RangeIndex(3) |
| 294 | + x = gppd.add_vars(self.model, index, lb=1.0, ub=2.0, name="x") |
| 295 | + y = gppd.add_vars(self.model, index, obj=1.0, name="y") |
| 296 | + self.model.ModelSense = GRB.MAXIMIZE |
| 297 | + |
| 298 | + gppd.add_constrs(self.model, y, GRB.EQUAL, x.apply(nlfunc.log), name="log_x") |
| 299 | + |
| 300 | + self.model.optimize() |
| 301 | + self.assert_approx_equal(self.model.ObjVal, 3 * math.log(2.0)) |
| 302 | + |
| 303 | + def test_inequality(self): |
| 304 | + # max sum x_i |
| 305 | + # s.t. log2(x_i^2 + 1) <= 2.0 |
| 306 | + # 0.0 <= x <= 1.0 |
| 307 | + # |
| 308 | + # Formulated as |
| 309 | + # |
| 310 | + # max sum x_i |
| 311 | + # s.t. log2(x_i^2 + 1) == z_i |
| 312 | + # 0.0 <= x <= 1.0 |
| 313 | + # -GRB.INFINITY <= z_i <= 2 |
| 314 | + from gurobipy import nlfunc |
| 315 | + |
| 316 | + index = pd.RangeIndex(3) |
| 317 | + x = gppd.add_vars(self.model, index, name="x") |
| 318 | + z = gppd.add_vars(self.model, index, lb=-GRB.INFINITY, ub=2.0, name="z") |
| 319 | + self.model.setObjective(x.sum(), sense=GRB.MAXIMIZE) |
| 320 | + |
| 321 | + gppd.add_constrs(self.model, z, GRB.EQUAL, (x**2 + 1).apply(nlfunc.log)) |
| 322 | + |
| 323 | + self.model.optimize() |
| 324 | + self.model.write("model.lp") |
| 325 | + self.model.write("model.sol") |
| 326 | + |
| 327 | + x_sol = x.gppd.X |
| 328 | + z_sol = z.gppd.X |
| 329 | + for i in range(3): |
| 330 | + self.assert_approx_equal(x_sol[i], math.sqrt(math.exp(2.0) - 1)) |
| 331 | + self.assert_approx_equal(z_sol[i], 2.0) |
| 332 | + |
| 333 | + def test_wrong_usage(self): |
| 334 | + index = pd.RangeIndex(3) |
| 335 | + x = gppd.add_vars(self.model, index, name="x") |
| 336 | + y = gppd.add_vars(self.model, index, name="y") |
| 337 | + |
| 338 | + with self.assertRaisesRegex( |
| 339 | + gp.GurobiError, "Objective must be linear or quadratic" |
| 340 | + ): |
| 341 | + self.model.setObjective((x / y).sum()) |
| 342 | + |
| 343 | + with self.assertRaisesRegex( |
| 344 | + ValueError, "Nonlinear constraints must be in the form" |
| 345 | + ): |
| 346 | + gppd.add_constrs(self.model, y, GRB.LESS_EQUAL, x**4) |
| 347 | + |
| 348 | + with self.assertRaisesRegex( |
| 349 | + ValueError, "Nonlinear constraints must be in the form" |
| 350 | + ): |
| 351 | + gppd.add_constrs(self.model, y + x**4, GRB.EQUAL, 1) |
| 352 | + |
| 353 | + with self.assertRaisesRegex( |
| 354 | + ValueError, "Nonlinear constraints must be in the form" |
| 355 | + ): |
| 356 | + gppd.add_constrs(self.model, y**4, GRB.EQUAL, x) |
| 357 | + |
| 358 | + with self.assertRaisesRegex( |
| 359 | + ValueError, "Nonlinear constraints must be in the form" |
| 360 | + ): |
| 361 | + x.to_frame().gppd.add_constrs(self.model, "x**3 == 1", name="bad") |
| 362 | + |
| 363 | + def test_eval(self): |
| 364 | + index = pd.RangeIndex(3) |
| 365 | + df = ( |
| 366 | + gppd.add_vars(self.model, index, name="x") |
| 367 | + .to_frame() |
| 368 | + .gppd.add_vars(self.model, name="y") |
| 369 | + .gppd.add_constrs(self.model, "y == x**3", name="nlconstr") |
| 370 | + ) |
| 371 | + |
| 372 | + self.model.update() |
| 373 | + for row in df.itertuples(index=False): |
| 374 | + self.assert_nlconstr_equal( |
| 375 | + row.nlconstr, |
| 376 | + row.y, |
| 377 | + [ |
| 378 | + (GRB.OPCODE_POW, -1.0, -1), |
| 379 | + (GRB.OPCODE_VARIABLE, row.x, 0), |
| 380 | + (GRB.OPCODE_CONSTANT, 3.0, 0), |
| 381 | + ], |
| 382 | + ) |
| 383 | + |
| 384 | + def test_frame(self): |
| 385 | + from gurobipy import nlfunc |
| 386 | + |
| 387 | + index = pd.RangeIndex(3) |
| 388 | + df = ( |
| 389 | + gppd.add_vars(self.model, index, name="x") |
| 390 | + .to_frame() |
| 391 | + .gppd.add_vars(self.model, name="y") |
| 392 | + .assign(exp_x=lambda df: df["x"].apply(nlfunc.exp)) |
| 393 | + .gppd.add_constrs(self.model, "y", GRB.EQUAL, "exp_x", name="nlconstr") |
| 394 | + ) |
| 395 | + |
| 396 | + self.model.update() |
| 397 | + for row in df.itertuples(index=False): |
| 398 | + self.assert_nlconstr_equal( |
| 399 | + row.nlconstr, |
| 400 | + row.y, |
| 401 | + [ |
| 402 | + (GRB.OPCODE_EXP, -1.0, -1), |
| 403 | + (GRB.OPCODE_VARIABLE, row.x, 0), |
| 404 | + ], |
| 405 | + ) |
| 406 | + |
| 407 | + |
272 | 408 | class TestDataValidation(GurobiModelTestCase):
|
273 | 409 | # Test that we throw some more informative errors, instead of obscure
|
274 | 410 | # ones from the underlying gurobipy library
|
|
0 commit comments