From 5d4636078b98143e75f980c3abeff56d95f8291d Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 8 Dec 2024 19:41:24 +0100 Subject: [PATCH 001/135] add first draft of piecewise linear functions --- src/gamspy/formulations/__init__.py | 2 + src/gamspy/formulations/piecewise.py | 161 +++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/gamspy/formulations/piecewise.py diff --git a/src/gamspy/formulations/__init__.py b/src/gamspy/formulations/__init__.py index f3373d7e..b04e4483 100644 --- a/src/gamspy/formulations/__init__.py +++ b/src/gamspy/formulations/__init__.py @@ -6,6 +6,7 @@ MaxPool2d, MinPool2d, ) +from gamspy.formulations.piecewise import piecewise_linear_function from gamspy.formulations.shape import flatten_dims __all__ = [ @@ -16,4 +17,5 @@ "AvgPool2d", "Linear", "flatten_dims", + "piecewise_linear_function", ] diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py new file mode 100644 index 00000000..6a42b4c5 --- /dev/null +++ b/src/gamspy/formulations/piecewise.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import math +import typing + +import numpy as np + +import gamspy as gp +from gamspy.exceptions import ValidationError + +number = int | float + + +def _generate_gray_code(n: int, n_bits: int) -> np.ndarray: + """ + Returns an n x n_bits NumPy array containing gray codes. + The bit difference between two consecutive rows is exactly + 1 bits. Required for the log piecewise linear formulation. + """ + a = np.arange(n) + b = a >> 1 + numbers = a ^ b + numbers_in_bit_array = ( + (numbers[:, None] & (1 << np.arange(n_bits))) > 0 + ).astype(int) + return numbers_in_bit_array + + +def _check_points( + x_to_fx: dict[number, number], +) -> tuple[list[number], list[number]]: + if not isinstance(x_to_fx, dict): + raise ValidationError("Function mapping must be a dictionary") + + x_vals = [] + y_vals = [] + + old_k = None + for k in x_to_fx: + if not isinstance(k, (float, int)): + raise ValidationError("Keys need to be float or integer") + + v = x_to_fx[k] + if not isinstance(v, (float, int)): + raise ValidationError("Values need to be float or integer") + + if old_k is None: + old_k = k + elif k <= old_k: + raise ValidationError("Keys need to be sorted") + + x_vals.append(k) + y_vals.append(v) + + return x_vals, y_vals + + +def enforce_sos2_with_binary(lambda_var: gp.Variable): + equations = [] + m = lambda_var.container + count_x = len(lambda_var.domain[-1]) + + J = lambda_var.domain[-1] + + l_len = math.ceil(math.log2(count_x - 1)) + I, L = gp.math._generate_dims( + m, + [ + count_x - 1, + l_len, + ], + ) + + bin_var = m.addVariable(domain=[L], type="binary") + gray_code = _generate_gray_code(count_x - 1, l_len) + + B = m.addParameter(domain=[I, L], records=gray_code) + + JI = m.addSet(domain=[J, I]) + JI[J, I].where[(gp.Ord(J) == gp.Ord(I)) | (gp.Ord(J) - 1 == gp.Ord(I))] = 1 + + use_set_1 = m.addSet(domain=[L, J]) + use_set_1[L, J].where[gp.Smin(JI[J, I], B[I, L]) == 1] = 1 + + use_set_2 = m.addSet(domain=[L, J]) + use_set_2[L, J].where[gp.Smax(JI[J, I], B[I, L]) == 0] = 1 + + sos2_eq_1 = m.addEquation(domain=[L]) + sos2_eq_1[L] = gp.Sum(use_set_1[L, J], lambda_var[J]) <= bin_var[L] + equations.append(sos2_eq_1) + + sos2_eq_2 = m.addEquation(domain=[L]) + sos2_eq_2[L] = gp.Sum(use_set_2[L, J], lambda_var[J]) <= 1 - bin_var[L] + equations.append(sos2_eq_2) + + return lambda_var, equations + + +def piecewise_linear_function( + input_x: gp.Variable, + points: dict[number, number], + using: typing.Literal["binary", "sos2"] = "sos2", +) -> tuple[gp.Variable, list[gp.Equation]]: + if using not in {"binary", "sos2"}: + raise ValidationError( + "Invalid value for the using argument." + "Possible values are 'binary' and 'sos2'" + ) + + x_vals, y_vals = _check_points(points) + + m = input_x.container + out_y = m.addVariable() + equations = [] + + J = gp.math._generate_dims(m, [len(x_vals)])[0] + x_par = m.addParameter(domain=[J], records=np.array(x_vals)) + y_par = m.addParameter(domain=[J], records=np.array(y_vals)) + + min_y = min(y_vals) + max_y = max(y_vals) + out_y.lo[...] = min_y + out_y.up[...] = max_y + + lambda_var = m.addVariable( + domain=[J], type="free" if using == "binary" else "sos2" + ) + lambda_var.lo[...] = 0 + lambda_var.up[...] = 1 + + lambda_sum = m.addEquation() + lambda_sum[...] = gp.Sum(J, lambda_var) == 1 + equations.append(lambda_sum) + + set_x = m.addEquation() + set_x[...] = input_x == gp.Sum(J, x_par * lambda_var) + equations.append(set_x) + + set_y = m.addEquation() + set_y[...] = out_y == gp.Sum(J, y_par * lambda_var) + equations.append(set_y) + + if using == "binary": + _, extra_eqs = enforce_sos2_with_binary(lambda_var) + equations.extend(extra_eqs) + + return out_y, equations + + +def piecewise_linear_function_with_binary( + input_x: gp.Variable, + points: dict[number, number], +) -> tuple[gp.Variable, list[gp.Equation]]: + return piecewise_linear_function(input_x, points, using="binary") + + +def piecewise_linear_function_with_sos2( + input_x: gp.Variable, + points: dict[number, number], +) -> tuple[gp.Variable, list[gp.Equation]]: + return piecewise_linear_function(input_x, points, using="sos2") From 8eb166764d3ab6b99fa40c7d4ef5a945b22b3556 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 8 Dec 2024 19:56:01 +0100 Subject: [PATCH 002/135] use typing.Union instead of bar in pwl --- src/gamspy/formulations/piecewise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 6a42b4c5..7989bf20 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -8,7 +8,7 @@ import gamspy as gp from gamspy.exceptions import ValidationError -number = int | float +number = typing.Union[int, float] def _generate_gray_code(n: int, n_bits: int) -> np.ndarray: From d7dd0f0e821e7a1a6acfa6509b6dac97c7f1c960 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Tue, 10 Dec 2024 14:35:06 +0100 Subject: [PATCH 003/135] make sos2 with binary function internal --- src/gamspy/formulations/piecewise.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 7989bf20..36430655 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -55,7 +55,7 @@ def _check_points( return x_vals, y_vals -def enforce_sos2_with_binary(lambda_var: gp.Variable): +def _enforce_sos2_with_binary(lambda_var: gp.Variable): equations = [] m = lambda_var.container count_x = len(lambda_var.domain[-1]) @@ -141,7 +141,7 @@ def piecewise_linear_function( equations.append(set_y) if using == "binary": - _, extra_eqs = enforce_sos2_with_binary(lambda_var) + _, extra_eqs = _enforce_sos2_with_binary(lambda_var) equations.extend(extra_eqs) return out_y, equations From 884cf02fe06e6b86f8a340686f06cb10d42dfb2e Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Tue, 10 Dec 2024 23:05:40 +0100 Subject: [PATCH 004/135] change input to piecewise_linear_function to intervals --- src/gamspy/formulations/piecewise.py | 80 ++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 36430655..9576f3f9 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -9,6 +9,7 @@ from gamspy.exceptions import ValidationError number = typing.Union[int, float] +linear_expression = typing.Union["gp.Expression", "gp.Variable", number] def _generate_gray_code(n: int, n_bits: int) -> np.ndarray: @@ -26,31 +27,68 @@ def _generate_gray_code(n: int, n_bits: int) -> np.ndarray: return numbers_in_bit_array +def _get_linear_coefficients(expr: linear_expression) -> tuple[float, float]: + """ + Assuming the provided expression is in shape y = mx +n, it returns the + coefficients m, n + """ + # constant y = c + if isinstance(expr, (int, float)): + return 0, expr + + # y = x + if isinstance(expr, gp.Variable): + return 1, 0 + + # TODO implement + return 1, 2 + + def _check_points( - x_to_fx: dict[number, number], + intervals: dict[tuple[number, number], linear_expression], ) -> tuple[list[number], list[number]]: - if not isinstance(x_to_fx, dict): + if not isinstance(intervals, dict): raise ValidationError("Function mapping must be a dictionary") + last_b = None + last_y = None x_vals = [] y_vals = [] - old_k = None - for k in x_to_fx: - if not isinstance(k, (float, int)): - raise ValidationError("Keys need to be float or integer") + for a, b in intervals: + if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): + raise ValidationError( + "Intervals must be specified using integers or floats" + ) + + if a > b: + raise ValidationError("Interval's start is greater than its end") + + if last_b is None: + last_b = a + + # TODO maybe we will relax it + if last_b != a: + raise ValidationError("Intervals cannot have any gap") + + last_b = b + expr = intervals[(a, b)] + if not isinstance(expr, (int, float, gp.Expression, gp.Variable)): + raise ValidationError("Expression was in an unrecognized format") + + m, n = _get_linear_coefficients(expr) - v = x_to_fx[k] - if not isinstance(v, (float, int)): - raise ValidationError("Values need to be float or integer") + y1 = m * a + n + y2 = m * b + n - if old_k is None: - old_k = k - elif k <= old_k: - raise ValidationError("Keys need to be sorted") + # discontinuity + if last_y != y1: + x_vals.append(a) + y_vals.append(y1) - x_vals.append(k) - y_vals.append(v) + x_vals.append(b) + y_vals.append(y2) + last_y = y2 return x_vals, y_vals @@ -98,7 +136,7 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable): def piecewise_linear_function( input_x: gp.Variable, - points: dict[number, number], + intervals: dict[tuple[number, number], linear_expression], using: typing.Literal["binary", "sos2"] = "sos2", ) -> tuple[gp.Variable, list[gp.Equation]]: if using not in {"binary", "sos2"}: @@ -107,7 +145,7 @@ def piecewise_linear_function( "Possible values are 'binary' and 'sos2'" ) - x_vals, y_vals = _check_points(points) + x_vals, y_vals = _check_points(intervals) m = input_x.container out_y = m.addVariable() @@ -149,13 +187,13 @@ def piecewise_linear_function( def piecewise_linear_function_with_binary( input_x: gp.Variable, - points: dict[number, number], + intervals: dict[tuple[number, number], linear_expression], ) -> tuple[gp.Variable, list[gp.Equation]]: - return piecewise_linear_function(input_x, points, using="binary") + return piecewise_linear_function(input_x, intervals, using="binary") def piecewise_linear_function_with_sos2( input_x: gp.Variable, - points: dict[number, number], + intervals: dict[tuple[number, number], linear_expression], ) -> tuple[gp.Variable, list[gp.Equation]]: - return piecewise_linear_function(input_x, points, using="sos2") + return piecewise_linear_function(input_x, intervals, using="sos2") From 9c9c64a7e1f6d6a2033c355944062f2657239ae0 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Wed, 11 Dec 2024 18:47:36 +0100 Subject: [PATCH 005/135] fix minor typos --- docs/user/ml/formulations.rst | 2 +- docs/user/ml/introduction.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/ml/formulations.rst b/docs/user/ml/formulations.rst index 6815ab07..24fdd28c 100644 --- a/docs/user/ml/formulations.rst +++ b/docs/user/ml/formulations.rst @@ -18,7 +18,7 @@ Layer Formulations ================== GAMSPy provides several formulations to help you embed your neural network -structures into your into your optimization model. We started with formulations +structures into your optimization model. We started with formulations for computer vision-related structures such as convolution and pooling operations. diff --git a/docs/user/ml/introduction.rst b/docs/user/ml/introduction.rst index 82a45c1c..851f01fe 100644 --- a/docs/user/ml/introduction.rst +++ b/docs/user/ml/introduction.rst @@ -30,7 +30,7 @@ is much easier than: .. code-block:: python - calc_mm_3[m, j] = z3[m, j] == Sum(k, a2[m, i] @ w2[i, j]) + calc_mm_3[m, j] = z3[m, j] == Sum(i, a2[m, i] @ w2[i, j]) In this context, ``m`` represents the batch dimension, ``i`` denotes the feature dimension of layer 2, and ``j`` represents the feature dimension of layer 3. From f09724845f56493ab74cdfdd5c86ae9c4548f70f Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Wed, 11 Dec 2024 18:50:26 +0100 Subject: [PATCH 006/135] apply suggestions --- docs/user/ml/nn.rst | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/user/ml/nn.rst b/docs/user/ml/nn.rst index a2adc4b5..c49e36b1 100644 --- a/docs/user/ml/nn.rst +++ b/docs/user/ml/nn.rst @@ -8,7 +8,7 @@ Neural Network Example :keywords: Machine Learning, User, Guide, GAMSPy, gamspy, GAMS, gams, mathematical modeling, sparsity, performance Our goal in implementing ML-related features was to provide maximum flexibility. -Although GAMSPy’s primary purpose is not neural network training, we wanted to +Although GAMSPy’s primary purpose is not neural network training, we wanted to demonstrate the process for those who are curious or need it for research. Implementing a "Mini-Batch Gradient Descent" training process would be very time-consuming and result in a non-introductory example. Therefore, we @@ -18,8 +18,8 @@ stopping after one mini-batch. We will train a neural network to classify handwritten digits from MNIST dataset. For this example we will use a simple feed-forward network since it is easier to implement and demonstrate. Our neural network has flattened images -in the input layer, resulting in a 28x28 = 784 dimension. We will use a single -hidden layer with 20 neurons, and the output layer will have 10 neurons +in the input layer, resulting in a 28x28 = 784 dimension. We will use a single +hidden layer with 20 neurons, and the output layer will have 10 neurons corresponding to 10 digits. We start with the imports: @@ -442,11 +442,8 @@ However, guessing a bound is not trivial. We demonstrated the flexibility of GAMSPy by training a simple neural -network. If your primary goal is to train a neural network, using frameworks -like `PyTorch `_ or `TensorFlow `_ -would be easier and faster. However, for research purposes and curious users, -it is interesting to show how black-box solvers can handle neural network -training. +network. For research purposes and curious users, it is interesting to +see how black-box solvers can handle neural network training. Here are some points that can help with your research: From 48c06d4d6fa7f24e0001fdf79ec4a86c650366e8 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Thu, 12 Dec 2024 21:26:26 +0100 Subject: [PATCH 007/135] change input format for pwl and add api docs --- src/gamspy/formulations/__init__.py | 8 +- src/gamspy/formulations/piecewise.py | 206 +++++++++++++++++---------- 2 files changed, 135 insertions(+), 79 deletions(-) diff --git a/src/gamspy/formulations/__init__.py b/src/gamspy/formulations/__init__.py index b04e4483..c921050c 100644 --- a/src/gamspy/formulations/__init__.py +++ b/src/gamspy/formulations/__init__.py @@ -6,7 +6,11 @@ MaxPool2d, MinPool2d, ) -from gamspy.formulations.piecewise import piecewise_linear_function +from gamspy.formulations.piecewise import ( + piecewise_linear_function, + piecewise_linear_function_with_binary, + piecewise_linear_function_with_sos2, +) from gamspy.formulations.shape import flatten_dims __all__ = [ @@ -18,4 +22,6 @@ "Linear", "flatten_dims", "piecewise_linear_function", + "piecewise_linear_function_with_sos2", + "piecewise_linear_function_with_binary", ] diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 9576f3f9..38fb1ae5 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -27,73 +27,14 @@ def _generate_gray_code(n: int, n_bits: int) -> np.ndarray: return numbers_in_bit_array -def _get_linear_coefficients(expr: linear_expression) -> tuple[float, float]: - """ - Assuming the provided expression is in shape y = mx +n, it returns the - coefficients m, n +def _enforce_sos2_with_binary(lambda_var: gp.Variable): """ - # constant y = c - if isinstance(expr, (int, float)): - return 0, expr - - # y = x - if isinstance(expr, gp.Variable): - return 1, 0 - - # TODO implement - return 1, 2 - - -def _check_points( - intervals: dict[tuple[number, number], linear_expression], -) -> tuple[list[number], list[number]]: - if not isinstance(intervals, dict): - raise ValidationError("Function mapping must be a dictionary") - - last_b = None - last_y = None - x_vals = [] - y_vals = [] - - for a, b in intervals: - if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): - raise ValidationError( - "Intervals must be specified using integers or floats" - ) - - if a > b: - raise ValidationError("Interval's start is greater than its end") - - if last_b is None: - last_b = a + Enforces SOS2 constraints using binary variables. - # TODO maybe we will relax it - if last_b != a: - raise ValidationError("Intervals cannot have any gap") - - last_b = b - expr = intervals[(a, b)] - if not isinstance(expr, (int, float, gp.Expression, gp.Variable)): - raise ValidationError("Expression was in an unrecognized format") - - m, n = _get_linear_coefficients(expr) - - y1 = m * a + n - y2 = m * b + n - - # discontinuity - if last_y != y1: - x_vals.append(a) - y_vals.append(y1) - - x_vals.append(b) - y_vals.append(y2) - last_y = y2 - - return x_vals, y_vals - - -def _enforce_sos2_with_binary(lambda_var: gp.Variable): + Based on paper: + `Modeling disjunctive constraints with a logarithmic number of binary variables and constraints + `_ + """ equations = [] m = lambda_var.container count_x = len(lambda_var.domain[-1]) @@ -131,32 +72,109 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable): sos2_eq_2[L] = gp.Sum(use_set_2[L, J], lambda_var[J]) <= 1 - bin_var[L] equations.append(sos2_eq_2) - return lambda_var, equations + return equations + + +def _check_points( + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], +) -> None: + if not isinstance(x_points, typing.Sequence): + raise ValidationError("x_points are expected to be a sequence") + + if not isinstance(y_points, typing.Sequence): + raise ValidationError("y_points are expected to be a sequence") + + if len(x_points) <= 2: + raise ValidationError( + "piecewise linear functions require at least 2 points" + ) + + if len(y_points) != len(x_points): + raise ValidationError("x_points and y_points have different lenghts") + + for li, name in [(x_points, "x_points"), (y_points, "y_points")]: + for item in li: + if not isinstance(item, (float, int)): + raise ValidationError(f"{name} contains non-numerical items") + + for i in range(len(x_points) - 1): + if x_points[i + 1] < x_points[i]: + raise ValidationError( + "x_points should be in an non-decreasing order" + ) def piecewise_linear_function( input_x: gp.Variable, - intervals: dict[tuple[number, number], linear_expression], + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], using: typing.Literal["binary", "sos2"] = "sos2", ) -> tuple[gp.Variable, list[gp.Equation]]: + """ + This function implements a piecewise linear function. Given an input + (independent) variable `input_x`, along with the defining `x_points` and + corresponding `y_points` of the piecewise function, it constructs the + dependent variable `y` and formulates the equations necessary to define the + function. + + The implementation supports discontinuities. If the function is + discontinuous at a specific point `x_i`, you can specify `x_i` twice in + `x_points` with distinct y_points values. For instance, with `x_points` + = `[1, 3, 3, 5]` and `y_points` = `[10, 30, 50, 70]`, the function allows + `y` to take either 30 or 50 at `x=3`. + + The input variable `input_x` is restricted to the range defined by + `x_points`. + + Internally, the function uses SOS2 (Special Ordered Set Type 2) variables + by default. If preferred, you can switch to binary variables by setting the + `using` parameter to "binary". + + Returns the dependent variable `y` and the equations required to model the + piecewise linear relationship. + + Parameters + ---------- + x : gp.Variable + Independent variable of the piecewise linear function + x_points: typing.Sequence[int | float] + Break points of the piecewise linear function in the x-axis + y_points: typing.Sequence[int| float] + Break points of the piecewise linear function in the y-axis + using: str = "sos2" + + Returns + ------- + tuple[gp.Variable, list[Equation]] + + Examples + -------- + >>> from gamspy import Container, Variable, Set + >>> from gamspy.formulations import piecewise_linear_function + >>> m = Container() + >>> x = Variable(m, "x") + >>> y, eqs = piecewise_linear_function(x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37]) + + """ if using not in {"binary", "sos2"}: raise ValidationError( "Invalid value for the using argument." "Possible values are 'binary' and 'sos2'" ) - x_vals, y_vals = _check_points(intervals) + _check_points(x_points, y_points) m = input_x.container out_y = m.addVariable() equations = [] - J = gp.math._generate_dims(m, [len(x_vals)])[0] - x_par = m.addParameter(domain=[J], records=np.array(x_vals)) - y_par = m.addParameter(domain=[J], records=np.array(y_vals)) + J = gp.math._generate_dims(m, [len(x_points)])[0] + x_par = m.addParameter(domain=[J], records=np.array(x_points)) + y_par = m.addParameter(domain=[J], records=np.array(y_points)) - min_y = min(y_vals) - max_y = max(y_vals) + min_y = min(y_points) + max_y = max(y_points) out_y.lo[...] = min_y out_y.up[...] = max_y @@ -179,7 +197,7 @@ def piecewise_linear_function( equations.append(set_y) if using == "binary": - _, extra_eqs = _enforce_sos2_with_binary(lambda_var) + extra_eqs = _enforce_sos2_with_binary(lambda_var) equations.extend(extra_eqs) return out_y, equations @@ -187,13 +205,45 @@ def piecewise_linear_function( def piecewise_linear_function_with_binary( input_x: gp.Variable, - intervals: dict[tuple[number, number], linear_expression], + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], ) -> tuple[gp.Variable, list[gp.Equation]]: - return piecewise_linear_function(input_x, intervals, using="binary") + """ + Calls the piecewise_linear_function setting `using` keyword argument + to `binary` + + Parameters + ---------- + x : gp.Variable + Independent variable of the piecewise linear function + x_points: typing.Sequence[int | float] + Break points of the piecewise linear function in the x-axis + y_points: typing.Sequence[int| float] + Break points of the piecewise linear function in the y-axis + + """ + return piecewise_linear_function( + input_x, x_points, y_points, using="binary" + ) def piecewise_linear_function_with_sos2( input_x: gp.Variable, - intervals: dict[tuple[number, number], linear_expression], + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], ) -> tuple[gp.Variable, list[gp.Equation]]: - return piecewise_linear_function(input_x, intervals, using="sos2") + """ + Calls the piecewise_linear_function setting `using` keyword argument + to `sos2`. + + Parameters + ---------- + x : gp.Variable + Independent variable of the piecewise linear function + x_points: typing.Sequence[int | float] + Break points of the piecewise linear function in the x-axis + y_points: typing.Sequence[int| float] + Break points of the piecewise linear function in the y-axis + + """ + return piecewise_linear_function(input_x, x_points, y_points, using="sos2") From 27eec691c07fcdabe9f9c215735e645cf8bb188c Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Thu, 12 Dec 2024 21:51:43 +0100 Subject: [PATCH 008/135] add validation tests for piecewise_linear_function --- src/gamspy/formulations/piecewise.py | 3 + tests/unit/test_formulation.py | 123 +++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/unit/test_formulation.py diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 38fb1ae5..655000ca 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -163,6 +163,9 @@ def piecewise_linear_function( "Possible values are 'binary' and 'sos2'" ) + if not isinstance(input_x, gp.Variable): + raise ValidationError("input_x is expected to be a Variable") + _check_points(x_points, y_points) m = input_x.container diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py new file mode 100644 index 00000000..45eb7f00 --- /dev/null +++ b/tests/unit/test_formulation.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import pytest + +import gamspy as gp +from gamspy.exceptions import ValidationError +from gamspy.formulations import ( + piecewise_linear_function, +) + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def data(): + m = gp.Container() + x = gp.Variable(m, "x") + x_points = [-10, 2.2, 5, 10] + y_points = [10, 20, -2, -5] + return { + "m": m, + "x": x, + "x_points": x_points, + "y_points": y_points, + } + + +def test_pwl_validation(data): + x = data["x"] + x_points = data["x_points"] + y_points = data["y_points"] + + # incorrect using value + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + x_points, + y_points, + "hello", + ) + + # x not a variable + pytest.raises( + ValidationError, + piecewise_linear_function, + 10, + x_points, + y_points, + ) + + # incorrect x_points, y_points + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + 10, + y_points, + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + x_points, + 10, + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [1], + [10], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + x_points, + [10], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [*x_points, "a"], + [*y_points, 5], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [*x_points, 16], + [*y_points, "a"], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [3, 2, 1], + [10, 20, 30], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [3, 1, 2], + [10, 20, 30], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [1, 3, 2], + [10, 20, 30], + ) From 8fb3bc67f493e5021340428e35807fe97a988e90 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Thu, 12 Dec 2024 23:41:54 +0100 Subject: [PATCH 009/135] add more tests for piecewise linear functions --- tests/unit/test_formulation.py | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 45eb7f00..13a6c39b 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -3,9 +3,12 @@ import pytest import gamspy as gp +import gamspy.formulations.piecewise as piecewise from gamspy.exceptions import ValidationError from gamspy.formulations import ( piecewise_linear_function, + piecewise_linear_function_with_binary, + piecewise_linear_function_with_sos2, ) pytestmark = pytest.mark.unit @@ -25,6 +28,86 @@ def data(): } +def get_var_count_by_type(m: gp.Container) -> dict[str, int]: + count = {} + for k in m.data: + symbol = m.data[k] + if not isinstance(symbol, gp.Variable): + continue + + sym_type = symbol.type + if sym_type not in count: + count[sym_type] = 0 + + count[sym_type] += 1 + + return count + + +def test_pwl_enforce_sos2_log_binary(): + m = gp.Container() + i = gp.Set(m, name="i", records=["1", "2", "3"]) + lambda_var = gp.Variable(m, name="lambda", domain=[i]) + # this will create binary variables + eqs = piecewise._enforce_sos2_with_binary(lambda_var) + assert len(eqs) == 2 + var_count = get_var_count_by_type(m) + assert var_count["binary"] == 1 + + +def test_pwl_gray_code(): + for n, m in [(2, 1), (3, 2), (4, 2), (5, 3), (8, 3), (513, 10), (700, 10)]: + code = piecewise._generate_gray_code(n, m) + old = None + for row in code: + if old is None: + old = row + continue + + diff = old - row + count = 0 + for col in diff: + count += abs(col) + + # in gray code consecutive two rows differ by 1 bit + assert count == 1, "Gray code row had more than 1 change" + old = row + + +def test_pwl_with_sos2(data): + m = data["m"] + x = data["x"] + x_points = data["x_points"] + y_points = data["y_points"] + y, eqs = piecewise_linear_function_with_sos2(x, x_points, y_points) + y2, eqs2 = piecewise_linear_function(x, x_points, y_points, using="sos2") + + # there should be no binary variables + var_count = get_var_count_by_type(m) + assert "binary" not in var_count + assert var_count["sos2"] == 2 # since we called it twice + assert y.type == "free" + assert y2.type == "free" + assert len(eqs) == len(eqs2) + + +def test_pwl_with_binary(data): + m = data["m"] + x = data["x"] + x_points = data["x_points"] + y_points = data["y_points"] + y, eqs = piecewise_linear_function_with_binary(x, x_points, y_points) + y2, eqs2 = piecewise_linear_function(x, x_points, y_points, using="binary") + + # there should be no sos2 variables + var_count = get_var_count_by_type(m) + assert "sos2" not in var_count + assert var_count["binary"] == 2 # since we called it twice + assert y.type == "free" + assert y2.type == "free" + assert len(eqs) == len(eqs2) + + def test_pwl_validation(data): x = data["x"] x_points = data["x_points"] From d77a9e6499639c45d6ea0095c2a15520be96158c Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sat, 14 Dec 2024 22:34:13 +0100 Subject: [PATCH 010/135] add integration tests for the piecewise linear functions --- src/gamspy/formulations/piecewise.py | 8 +- tests/integration/models/piecewiseLinear.py | 90 +++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 tests/integration/models/piecewiseLinear.py diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 655000ca..c7bb41db 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -35,9 +35,13 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable): `Modeling disjunctive constraints with a logarithmic number of binary variables and constraints `_ """ - equations = [] + equations: list[gp.Equation] = [] m = lambda_var.container count_x = len(lambda_var.domain[-1]) + # edge case + if count_x == 2: + # if there are only 2 elements, it is already sos2 + return equations J = lambda_var.domain[-1] @@ -85,7 +89,7 @@ def _check_points( if not isinstance(y_points, typing.Sequence): raise ValidationError("y_points are expected to be a sequence") - if len(x_points) <= 2: + if len(x_points) < 2: raise ValidationError( "piecewise linear functions require at least 2 points" ) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py new file mode 100644 index 00000000..dcbb3d26 --- /dev/null +++ b/tests/integration/models/piecewiseLinear.py @@ -0,0 +1,90 @@ +""" +## LICENSETYPE: Requires license +## MODELTYPE: MIP +## KEYWORDS: piecewise linear function, binary, sos2 + + +Piecewise Linear +---------------- + +Description: A set of models for testing Piecewise Linear function implementation + +Usage: python piecewiseLinear.py +""" + +import numpy as np + +import gamspy as gp + + +def main(): + print("Piecewise linear function test model") + m = gp.Container() + x = gp.Variable(m, name="x") + + np.random.seed(1997) + x_points_1 = [ + int(x) + for x in sorted(np.random.randint(low=-1000, high=1000, size=(1000))) + ] + y_points_1 = [ + int(x) for x in (np.random.randint(low=-1000, high=1000, size=(1000))) + ] + xy = list(zip(x_points_1, y_points_1)) + max_pair = max(xy, key=lambda k: k[1]) + min_pair = min(xy, key=lambda k: k[1]) + + # A line segment between -1 and 1 + test_cases = [ + ([-1, 1], [-5, 5], -5, 5, -1, 1), + ([-1.1, 1, 100], [-5.2, 5, 20], -5.2, 20, -1.1, 100), + ([-1, 1, 1], [5, -5, -10], -10, 5, 1, -1), + ( + [-1, -1, 1], + [5, -5, 0], + -5, + 5, + -1, + -1, + ), + ( + x_points_1, + y_points_1, + min_pair[1], + max_pair[1], + min_pair[0], + max_pair[0], + ), + ] + + for case_i, ( + x_points, + y_points, + exp_min, + exp_max, + x_at_min, + x_at_max, + ) in enumerate(test_cases): + for sense, expected_y, expected_x in [ + ("min", exp_min, x_at_min), + ("max", exp_max, x_at_max), + ]: + for using in ["sos2", "binary"]: + y, eqs = gp.formulations.piecewise_linear_function( + x, + x_points, + y_points, + using=using, + ) + model = gp.Model( + m, equations=eqs, objective=y, sense=sense, problem="mip" + ) + model.solve() + assert y.toDense() == expected_y + assert x.toDense() == expected_x + + print(f"Case {case_i} passed !") + + +if __name__ == "__main__": + main() From 1c7867ffd9ee1df78daf970f4ad8b27c5eded05f Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 15 Dec 2024 00:32:25 +0100 Subject: [PATCH 011/135] minor change in tests for piecewise linear function --- tests/unit/test_formulation.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 13a6c39b..f4d61b52 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -204,3 +204,11 @@ def test_pwl_validation(data): [1, 3, 2], [10, 20, 30], ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [1], + [10], + ) From 35ac9b54d8c806f2bf6a33a18d533cf3288f52a3 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Mon, 16 Dec 2024 10:57:06 +0100 Subject: [PATCH 012/135] extend piecewise_linear_functions for unbounded cases --- src/gamspy/formulations/piecewise.py | 37 ++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index c7bb41db..730ef166 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -114,6 +114,7 @@ def piecewise_linear_function( x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], using: typing.Literal["binary", "sos2"] = "sos2", + bound_domain: bool = True, ) -> tuple[gp.Variable, list[gp.Equation]]: """ This function implements a piecewise linear function. Given an input @@ -129,7 +130,9 @@ def piecewise_linear_function( `y` to take either 30 or 50 at `x=3`. The input variable `input_x` is restricted to the range defined by - `x_points`. + `x_points` unless `bound_domain` is set to False. `bound_domain` can be set + to False only if using is "sos2". When `input_x` is not bound, you can assume + as if the first and the last line segments are extended. Internally, the function uses SOS2 (Special Ordered Set Type 2) variables by default. If preferred, you can switch to binary variables by setting the @@ -147,6 +150,9 @@ def piecewise_linear_function( y_points: typing.Sequence[int| float] Break points of the piecewise linear function in the y-axis using: str = "sos2" + What type of variable is used during implementing piecewise function + bound_domain: bool = True + If input_x should be limited to interval defined by min(x_points), max(x_points) Returns ------- @@ -170,6 +176,11 @@ def piecewise_linear_function( if not isinstance(input_x, gp.Variable): raise ValidationError("input_x is expected to be a Variable") + if bound_domain is False and using == "binary": + raise ValidationError( + "bound_domain can only be false when using is sos2" + ) + _check_points(x_points, y_points) m = input_x.container @@ -180,16 +191,25 @@ def piecewise_linear_function( x_par = m.addParameter(domain=[J], records=np.array(x_points)) y_par = m.addParameter(domain=[J], records=np.array(y_points)) - min_y = min(y_points) - max_y = max(y_points) - out_y.lo[...] = min_y - out_y.up[...] = max_y - lambda_var = m.addVariable( domain=[J], type="free" if using == "binary" else "sos2" ) + lambda_var.lo[...] = 0 lambda_var.up[...] = 1 + if not bound_domain: + # lower bounds + lambda_var.lo[J].where[gp.Ord(J) == 2] = float("-inf") + lambda_var.lo[J].where[gp.Ord(J) == gp.Card(J) - 1] = float("-inf") + + # upper bound + lambda_var.up[J].where[gp.Ord(J) == 1] = float("inf") + lambda_var.up[J].where[gp.Ord(J) == gp.Card(J)] = float("inf") + else: + min_y = min(y_points) + max_y = max(y_points) + out_y.lo[...] = min_y + out_y.up[...] = max_y lambda_sum = m.addEquation() lambda_sum[...] = gp.Sum(J, lambda_var) == 1 @@ -238,6 +258,7 @@ def piecewise_linear_function_with_sos2( input_x: gp.Variable, x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], + bound_domain: bool = True, ) -> tuple[gp.Variable, list[gp.Equation]]: """ Calls the piecewise_linear_function setting `using` keyword argument @@ -253,4 +274,6 @@ def piecewise_linear_function_with_sos2( Break points of the piecewise linear function in the y-axis """ - return piecewise_linear_function(input_x, x_points, y_points, using="sos2") + return piecewise_linear_function( + input_x, x_points, y_points, using="sos2", bound_domain=bound_domain + ) From 09d9ee4f3bd7ab421c3e3dd1832b145591a88b93 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Mon, 16 Dec 2024 11:23:12 +0100 Subject: [PATCH 013/135] extends tests further for unbound cases --- tests/integration/models/piecewiseLinear.py | 56 ++++++++++++++++++++- tests/unit/test_formulation.py | 14 ++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index dcbb3d26..693dce0a 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -12,6 +12,8 @@ Usage: python piecewiseLinear.py """ +import math + import numpy as np import gamspy as gp @@ -80,11 +82,61 @@ def main(): m, equations=eqs, objective=y, sense=sense, problem="mip" ) model.solve() - assert y.toDense() == expected_y - assert x.toDense() == expected_x + assert y.toDense() == expected_y, f"Case {case_i} failed !" + assert x.toDense() == expected_x, f"Case {case_i} failed !" print(f"Case {case_i} passed !") + # test bound cases + # y is not bounded + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 2] + y, eqs = gp.formulations.piecewise_linear_function( + x, x_points, y_points, bound_domain=False + ) + x.fx[...] = -5 + model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") + model.solve() + + assert math.isclose(y.toDense(), -3), "Case 5 failed !" + print("Case 5 passed !") + x.fx[...] = 100 + model.solve() + assert math.isclose(y.toDense(), 99), "Case 6 failed !" + print("Case 6 passed !") + + # y is upper bounded + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 0] + y, eqs = gp.formulations.piecewise_linear_function( + x, x_points, y_points, bound_domain=False + ) + model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") + model.solve() + assert math.isclose(y.toDense(), 0), "Case 7 failed !" + print("Case 7 passed !") + x.fx[...] = 100 + model.solve() + assert math.isclose(y.toDense(), 0), "Case 8 failed !" + print("Case 8 passed !") + + # y is lower bounded + x_points = [-4, -2, 1, 3] + y_points = [-5, -5, 0, 2] + y, eqs = gp.formulations.piecewise_linear_function( + x, x_points, y_points, bound_domain=False + ) + x.lo[...] = "-inf" + x.up[...] = "inf" + model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") + model.solve() + assert math.isclose(y.toDense(), -5), "Case 9 failed !" + print("Case 9 passed !") + x.fx[...] = -100 + model.solve() + assert math.isclose(y.toDense(), -5), "Case 10 failed !" + print("Case 10 passed !") + if __name__ == "__main__": main() diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index f4d61b52..02dfc0df 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -81,6 +81,9 @@ def test_pwl_with_sos2(data): y_points = data["y_points"] y, eqs = piecewise_linear_function_with_sos2(x, x_points, y_points) y2, eqs2 = piecewise_linear_function(x, x_points, y_points, using="sos2") + y3, eqs2 = piecewise_linear_function_with_sos2( + x, x_points, y_points, bound_domain=False + ) # there should be no binary variables var_count = get_var_count_by_type(m) @@ -88,6 +91,7 @@ def test_pwl_with_sos2(data): assert var_count["sos2"] == 2 # since we called it twice assert y.type == "free" assert y2.type == "free" + assert y3.type == "free" assert len(eqs) == len(eqs2) @@ -212,3 +216,13 @@ def test_pwl_validation(data): [1], [10], ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [1, 2, 3], + [10, 20, 40], + using="binary", + bound_domain=True, + ) From 7bc3f406c888dc624559b37f5f9c96bba8244c54 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Mon, 16 Dec 2024 11:37:00 +0100 Subject: [PATCH 014/135] fix the failing test case --- tests/unit/test_formulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 02dfc0df..3c7ff6fd 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -88,7 +88,7 @@ def test_pwl_with_sos2(data): # there should be no binary variables var_count = get_var_count_by_type(m) assert "binary" not in var_count - assert var_count["sos2"] == 2 # since we called it twice + assert var_count["sos2"] == 3 # since we called it twice assert y.type == "free" assert y2.type == "free" assert y3.type == "free" From a2d31f135cb1ca103c9c4e153b4bb0659395d50a Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Mon, 16 Dec 2024 11:46:59 +0100 Subject: [PATCH 015/135] fix more failing tests --- tests/unit/test_formulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 3c7ff6fd..cc2527ca 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -224,5 +224,5 @@ def test_pwl_validation(data): [1, 2, 3], [10, 20, 40], using="binary", - bound_domain=True, + bound_domain=False, ) From f681612c0a0bb488bb418b5ca020e9aab2f000b1 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Mon, 16 Dec 2024 17:14:01 +0100 Subject: [PATCH 016/135] fix discontinuous issue with piecewise linear function and add tests --- src/gamspy/formulations/piecewise.py | 40 +++++++++++++++++++-- tests/integration/models/piecewiseLinear.py | 15 ++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 730ef166..5b31d7ba 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -82,7 +82,9 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable): def _check_points( x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], -) -> None: +) -> list[int]: + discontinuous_indices = [] + if not isinstance(x_points, typing.Sequence): raise ValidationError("x_points are expected to be a sequence") @@ -108,6 +110,11 @@ def _check_points( "x_points should be in an non-decreasing order" ) + if x_points[i] == x_points[i + 1]: + discontinuous_indices.append(i) + + return discontinuous_indices + def piecewise_linear_function( input_x: gp.Variable, @@ -181,13 +188,20 @@ def piecewise_linear_function( "bound_domain can only be false when using is sos2" ) - _check_points(x_points, y_points) + discontinuous_indices = _check_points(x_points, y_points) m = input_x.container out_y = m.addVariable() equations = [] - J = gp.math._generate_dims(m, [len(x_points)])[0] + if len(discontinuous_indices) > 0: + J, J2, SB = gp.math._generate_dims( + m, [len(x_points), len(x_points), len(discontinuous_indices)] + ) + else: + J = gp.math._generate_dims(m, [len(x_points)])[0] + SB = None + x_par = m.addParameter(domain=[J], records=np.array(x_points)) y_par = m.addParameter(domain=[J], records=np.array(y_points)) @@ -227,6 +241,26 @@ def piecewise_linear_function( extra_eqs = _enforce_sos2_with_binary(lambda_var) equations.extend(extra_eqs) + if len(discontinuous_indices) > 0: + di_param = [ + (str(i), str(j), str(j + 1)) + for i, j in enumerate(discontinuous_indices) + ] + select_set = m.addSet(domain=[SB, J, J2], records=di_param) + select_var = m.addVariable(domain=[SB], type="binary") + + select_equation = m.addEquation(domain=[SB, J, J2]) + select_equation[select_set[SB, J, J2]] = ( + lambda_var[J] <= select_var[SB] + ) + equations.append(select_equation) + + select_equation_2 = m.addEquation(domain=[SB, J, J2]) + select_equation_2[select_set[SB, J, J2]] = lambda_var[J2] <= ( + 1 - select_var[SB] + ) + equations.append(select_equation_2) + return out_y, equations diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index 693dce0a..8761f26f 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -137,6 +137,21 @@ def main(): assert math.isclose(y.toDense(), -5), "Case 10 failed !" print("Case 10 passed !") + # test discontinuous function not allowing in between value + x_points = [1, 4, 4, 10] + y_points = [1, 4, 8, 25] + y, eqs = gp.formulations.piecewise_linear_function( + x, x_points, y_points, bound_domain=True + ) + x.fx[...] = 4 + y.fx[...] = 6 + model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") + res = model.solve() + assert ( + res["Model Status"].item() == "IntegerInfeasible" + ), "Case 11 failed !" + print("Case 11 passed !") + if __name__ == "__main__": main() From 0e12e926419f13910b52f165c222fe1999d8aa6d Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Mon, 16 Dec 2024 18:12:45 +0100 Subject: [PATCH 017/135] extend piecewise linear function docs --- src/gamspy/formulations/piecewise.py | 11 ++++++----- tests/integration/models/piecewiseLinear.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 5b31d7ba..237d4b30 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -130,11 +130,12 @@ def piecewise_linear_function( dependent variable `y` and formulates the equations necessary to define the function. - The implementation supports discontinuities. If the function is - discontinuous at a specific point `x_i`, you can specify `x_i` twice in - `x_points` with distinct y_points values. For instance, with `x_points` - = `[1, 3, 3, 5]` and `y_points` = `[10, 30, 50, 70]`, the function allows - `y` to take either 30 or 50 at `x=3`. + The implementation handles discontinuities in the function. To represent a + discontinuity at a specific point `x_i`, include `x_i` twice in the `x_points` + array with corresponding values in `y_points`. For example, if `x_points` = + [1, 3, 3, 5] and `y_points` = [10, 30, 50, 70], the function allows y to take + either 30 or 50 when x = 3. Note that discontinuities always introduce + additional binary variables, regardless of the value of the using argument. The input variable `input_x` is restricted to the range defined by `x_points` unless `bound_domain` is set to False. `bound_domain` can be set diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index 8761f26f..4010123c 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -144,7 +144,7 @@ def main(): x, x_points, y_points, bound_domain=True ) x.fx[...] = 4 - y.fx[...] = 6 + y.fx[...] = 6 # y can be either 4 or 8 but not their convex combination model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") res = model.solve() assert ( From 0599268019b2a32f2682588e62aae5e764ee0ab5 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Tue, 17 Dec 2024 16:20:02 +0100 Subject: [PATCH 018/135] change default using to binary in pwl --- src/gamspy/formulations/piecewise.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 237d4b30..0ce0f1a1 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -120,7 +120,7 @@ def piecewise_linear_function( input_x: gp.Variable, x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], - using: typing.Literal["binary", "sos2"] = "sos2", + using: typing.Literal["binary", "sos2"] = "binary", bound_domain: bool = True, ) -> tuple[gp.Variable, list[gp.Equation]]: """ @@ -130,6 +130,11 @@ def piecewise_linear_function( dependent variable `y` and formulates the equations necessary to define the function. + Internally, the function uses binary variables by default. If preferred, + you can switch to SOS2 (Special Ordered Set Type 2) by setting the `using` + parameter to "sos2". `bound_domain` cannot be set to False while using + binary variable implementation. + The implementation handles discontinuities in the function. To represent a discontinuity at a specific point `x_i`, include `x_i` twice in the `x_points` array with corresponding values in `y_points`. For example, if `x_points` = @@ -142,10 +147,6 @@ def piecewise_linear_function( to False only if using is "sos2". When `input_x` is not bound, you can assume as if the first and the last line segments are extended. - Internally, the function uses SOS2 (Special Ordered Set Type 2) variables - by default. If preferred, you can switch to binary variables by setting the - `using` parameter to "binary". - Returns the dependent variable `y` and the equations required to model the piecewise linear relationship. From 0716168aba0a596ddf69a2fb7d88bc838250b883 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Tue, 17 Dec 2024 16:52:33 +0100 Subject: [PATCH 019/135] allow disallowing ranges in piecewise_linear_function --- src/gamspy/formulations/piecewise.py | 75 +++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 0ce0f1a1..121dfc2d 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -82,8 +82,11 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable): def _check_points( x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], -) -> list[int]: +) -> tuple[list[int | float], list[int | float], list[int], list[int]]: + return_x = [] + return_y = [] discontinuous_indices = [] + none_indices = [] if not isinstance(x_points, typing.Sequence): raise ValidationError("x_points are expected to be a sequence") @@ -99,21 +102,55 @@ def _check_points( if len(y_points) != len(x_points): raise ValidationError("x_points and y_points have different lenghts") - for li, name in [(x_points, "x_points"), (y_points, "y_points")]: - for item in li: - if not isinstance(item, (float, int)): - raise ValidationError(f"{name} contains non-numerical items") + if x_points[0] is None or x_points[-1] is None: + raise ValidationError("x_points cannot start or end with a None value") + + for x_p, y_p in zip(x_points, y_points): + if (x_p is None and y_p is not None) or ( + x_p is not None and y_p is None + ): + raise ValidationError( + "Both x and y must either be None or neither of them should be None" + ) + + if not isinstance(x_p, (float, int)) and x_p is not None: + raise ValidationError("x_points contains non-numerical items") + + if not isinstance(y_p, (float, int)) and y_p is not None: + raise ValidationError("y_points contains non-numerical items") for i in range(len(x_points) - 1): - if x_points[i + 1] < x_points[i]: + if x_points[i] is None and x_points[i + 1] is None: + raise ValidationError( + "x_points cannot contain two consecutive None values" + ) + + if x_points[i] is None and x_points[i - 1] >= x_points[i + 1]: + raise ValidationError( + "A value following a None must be strictly greater than the value preceding the None" + ) + + if ( + (x_points[i] is not None) + and (x_points[i + 1] is not None) + and (x_points[i + 1] < x_points[i]) + ): raise ValidationError( "x_points should be in an non-decreasing order" ) + if x_points[i] is not None: + return_x.append(x_points[i]) + return_y.append(y_points[i]) + if x_points[i] == x_points[i + 1]: - discontinuous_indices.append(i) + discontinuous_indices.append(len(return_x) - 1) + elif x_points[i] is None: + none_indices.append(len(return_x) - 1) - return discontinuous_indices + return_x.append(x_points[-1]) + return_y.append(y_points[-1]) + return return_x, return_y, discontinuous_indices, none_indices def piecewise_linear_function( @@ -142,6 +179,15 @@ def piecewise_linear_function( either 30 or 50 when x = 3. Note that discontinuities always introduce additional binary variables, regardless of the value of the using argument. + It is possible to disallow a specific range by including `None` in both + `x_points` and the corresponding `y_points`. For example, with + `x_points` = `[1, 3, None, 5, 7]` and `y_points` = `[10, 35, None, -20, 40]`, + the range between 3 and 5 is disallowed for `input_x`. + + However, `x_points` cannot start or end with a `None` value, and a `None` + value cannot be followed by another `None`. Additionally, if `x_i` is `None`, + then `y_i` must also be `None`." + The input variable `input_x` is restricted to the range defined by `x_points` unless `bound_domain` is set to False. `bound_domain` can be set to False only if using is "sos2". When `input_x` is not bound, you can assume @@ -190,15 +236,18 @@ def piecewise_linear_function( "bound_domain can only be false when using is sos2" ) - discontinuous_indices = _check_points(x_points, y_points) + x_points, y_points, discontinuous_indices, none_indices = _check_points( + x_points, y_points + ) + combined_indices = {*discontinuous_indices, *none_indices} m = input_x.container out_y = m.addVariable() equations = [] - if len(discontinuous_indices) > 0: + if len(combined_indices) > 0: J, J2, SB = gp.math._generate_dims( - m, [len(x_points), len(x_points), len(discontinuous_indices)] + m, [len(x_points), len(x_points), len(combined_indices)] ) else: J = gp.math._generate_dims(m, [len(x_points)])[0] @@ -243,10 +292,10 @@ def piecewise_linear_function( extra_eqs = _enforce_sos2_with_binary(lambda_var) equations.extend(extra_eqs) - if len(discontinuous_indices) > 0: + if len(combined_indices) > 0: di_param = [ (str(i), str(j), str(j + 1)) - for i, j in enumerate(discontinuous_indices) + for i, j in enumerate(combined_indices) ] select_set = m.addSet(domain=[SB, J, J2], records=di_param) select_var = m.addVariable(domain=[SB], type="binary") From f4f29e59f3ac53cfb37de56c2779a8d11d301642 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Tue, 17 Dec 2024 16:53:59 +0100 Subject: [PATCH 020/135] Add more tests --- tests/integration/models/piecewiseLinear.py | 46 +++++++++++++-- tests/unit/test_formulation.py | 63 +++++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index 4010123c..eceb43fb 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -92,7 +92,7 @@ def main(): x_points = [-4, -2, 1, 3] y_points = [-2, 0, 0, 2] y, eqs = gp.formulations.piecewise_linear_function( - x, x_points, y_points, bound_domain=False + x, x_points, y_points, using="sos2", bound_domain=False ) x.fx[...] = -5 model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") @@ -109,7 +109,7 @@ def main(): x_points = [-4, -2, 1, 3] y_points = [-2, 0, 0, 0] y, eqs = gp.formulations.piecewise_linear_function( - x, x_points, y_points, bound_domain=False + x, x_points, y_points, using="sos2", bound_domain=False ) model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") model.solve() @@ -124,7 +124,7 @@ def main(): x_points = [-4, -2, 1, 3] y_points = [-5, -5, 0, 2] y, eqs = gp.formulations.piecewise_linear_function( - x, x_points, y_points, bound_domain=False + x, x_points, y_points, using="sos2", bound_domain=False ) x.lo[...] = "-inf" x.up[...] = "inf" @@ -140,9 +140,7 @@ def main(): # test discontinuous function not allowing in between value x_points = [1, 4, 4, 10] y_points = [1, 4, 8, 25] - y, eqs = gp.formulations.piecewise_linear_function( - x, x_points, y_points, bound_domain=True - ) + y, eqs = gp.formulations.piecewise_linear_function(x, x_points, y_points) x.fx[...] = 4 y.fx[...] = 6 # y can be either 4 or 8 but not their convex combination model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") @@ -152,6 +150,42 @@ def main(): ), "Case 11 failed !" print("Case 11 passed !") + # test None case + x_points = [1, 4, None, 6, 10] + y_points = [1, 4, None, 8, 25] + y, eqs = gp.formulations.piecewise_linear_function(x, x_points, y_points) + x.fx[...] = 5 # should be IntegerInfeasible since 5 \in [4, 6] + model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") + res = model.solve() + assert ( + res["Model Status"].item() == "IntegerInfeasible" + ), "Case 12 failed !" + print("Case 12 passed !") + + # test None case + x_points = [1, 4, None, 6, 10] + y_points = [1, 4, None, 30, 25] + y, eqs = gp.formulations.piecewise_linear_function(x, x_points, y_points) + x.lo[...] = "-inf" + x.up[...] = "inf" + model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") + res = model.solve() + assert x.toDense() == 6, "Case 13 failed !" + assert y.toDense() == 30, "Case 13 failed !" + print("Case 13 passed !") + + # test None case + x_points = [1, 4, None, 6, 10] + y_points = [1, 45, None, 30, 25] + y, eqs = gp.formulations.piecewise_linear_function(x, x_points, y_points) + x.lo[...] = "-inf" + x.up[...] = "inf" + model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") + res = model.solve() + assert x.toDense() == 4, "Case 14 failed !" + assert y.toDense() == 45, "Case 14 failed !" + print("Case 14 passed !") + if __name__ == "__main__": main() diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index cc2527ca..d782e2c8 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -112,6 +112,13 @@ def test_pwl_with_binary(data): assert len(eqs) == len(eqs2) +def test_pwl_with_none(data): + x = data["x"] + x_points = [1, None, 2, 3] + y_points = [10, None, 20, 45] + y, eqs = piecewise_linear_function(x, x_points, y_points) + + def test_pwl_validation(data): x = data["x"] x_points = data["x_points"] @@ -226,3 +233,59 @@ def test_pwl_validation(data): using="binary", bound_domain=False, ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [None, 2, 3], + [None, 20, 40], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [2, 3, None], + [20, 40, None], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [None, 2, 3, None], + [None, 20, 40, None], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [0, None, 2, 3], + [0, 10, 20, 40], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [0, 1, 2, 3], + [0, None, 20, 40], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [1, None, None, 2, 3], + [10, None, None, 20, 40], + ) + + pytest.raises( + ValidationError, + piecewise_linear_function, + x, + [2, None, 2, 3], + [10, None, 20, 40], + ) From 9fc14bc1a25f42220690e7e8330bdfdcfc5d2d1a Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Tue, 17 Dec 2024 16:55:09 +0100 Subject: [PATCH 021/135] fix minor documentation issue --- src/gamspy/formulations/piecewise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 121dfc2d..1cacfee3 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -204,7 +204,7 @@ def piecewise_linear_function( Break points of the piecewise linear function in the x-axis y_points: typing.Sequence[int| float] Break points of the piecewise linear function in the y-axis - using: str = "sos2" + using: str = "binary" What type of variable is used during implementing piecewise function bound_domain: bool = True If input_x should be limited to interval defined by min(x_points), max(x_points) From b9a1ee8d745ad314ee0c6a49623cc311940620e2 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Thu, 19 Dec 2024 19:32:09 +0100 Subject: [PATCH 022/135] fix minor issues api docs --- src/gamspy/formulations/piecewise.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 1cacfee3..6a617efb 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -33,7 +33,7 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable): Based on paper: `Modeling disjunctive constraints with a logarithmic number of binary variables and constraints - `_ + `_ """ equations: list[gp.Equation] = [] m = lambda_var.container @@ -186,7 +186,9 @@ def piecewise_linear_function( However, `x_points` cannot start or end with a `None` value, and a `None` value cannot be followed by another `None`. Additionally, if `x_i` is `None`, - then `y_i` must also be `None`." + then `y_i` must also be `None`. Similar to the discontinuities, disallowed + ranges always introduce additional binary variables, regardless of the value + of the using argument. The input variable `input_x` is restricted to the range defined by `x_points` unless `bound_domain` is set to False. `bound_domain` can be set From 643172195ec384e562c35ddaffc1dc37717cf549 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Thu, 19 Dec 2024 21:14:34 +0100 Subject: [PATCH 023/135] update _enforce_sos2_with_binary to accept non scalar variables --- src/gamspy/formulations/piecewise.py | 22 +++++++++++++----- tests/unit/test_formulation.py | 34 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 6a617efb..e1c8c111 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -27,7 +27,7 @@ def _generate_gray_code(n: int, n_bits: int) -> np.ndarray: return numbers_in_bit_array -def _enforce_sos2_with_binary(lambda_var: gp.Variable): +def _enforce_sos2_with_binary(lambda_var: gp.Variable) -> list[gp.Equation]: """ Enforces SOS2 constraints using binary variables. @@ -44,6 +44,7 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable): return equations J = lambda_var.domain[-1] + previous_domains = lambda_var.domain[:-1] l_len = math.ceil(math.log2(count_x - 1)) I, L = gp.math._generate_dims( @@ -54,7 +55,10 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable): ], ) - bin_var = m.addVariable(domain=[L], type="binary") + J, I, L = gp.formulations.nn.utils._next_domains( + [J, I, L], previous_domains + ) + bin_var = m.addVariable(domain=[*previous_domains, L], type="binary") gray_code = _generate_gray_code(count_x - 1, l_len) B = m.addParameter(domain=[I, L], records=gray_code) @@ -68,12 +72,18 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable): use_set_2 = m.addSet(domain=[L, J]) use_set_2[L, J].where[gp.Smax(JI[J, I], B[I, L]) == 0] = 1 - sos2_eq_1 = m.addEquation(domain=[L]) - sos2_eq_1[L] = gp.Sum(use_set_1[L, J], lambda_var[J]) <= bin_var[L] + sos2_eq_1 = m.addEquation(domain=[*previous_domains, L]) + sos2_eq_1[[*previous_domains, L]] = ( + gp.Sum(use_set_1[L, J], lambda_var[[*previous_domains, J]]) + <= bin_var[*previous_domains, L] + ) equations.append(sos2_eq_1) - sos2_eq_2 = m.addEquation(domain=[L]) - sos2_eq_2[L] = gp.Sum(use_set_2[L, J], lambda_var[J]) <= 1 - bin_var[L] + sos2_eq_2 = m.addEquation(domain=[*previous_domains, L]) + sos2_eq_2[[*previous_domains, L]] = ( + gp.Sum(use_set_2[L, J], lambda_var[[*previous_domains, J]]) + <= 1 - bin_var[*previous_domains, L] + ) equations.append(sos2_eq_2) return equations diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index d782e2c8..a03de98b 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -55,6 +55,40 @@ def test_pwl_enforce_sos2_log_binary(): assert var_count["binary"] == 1 +def test_pwl_enforce_sos2_log_binary_with_domain(): + m = gp.Container() + j = gp.Set(m, name="j", records=["1", "2"]) + i = gp.Set(m, name="i", records=["1", "2", "3"]) + lambda_var = gp.Variable(m, name="lambda", domain=[j, i]) + # this will create binary variables + eqs = piecewise._enforce_sos2_with_binary(lambda_var) + assert len(eqs) == 2 + var_count = get_var_count_by_type(m) + assert var_count["binary"] == 1 + + for k in m.data: + sym = m.data[k] + if isinstance(sym, gp.Equation): + assert len(sym.domain) == 2 + assert sym.domain[0] == j + + +def test_pwl_enforce_sos2_log_binary_with_domain_2(): + m = gp.Container() + lambda_var = gp.Variable(m, name="lambda", domain=gp.math.dim([3, 8])) + # this will create binary variables + eqs = piecewise._enforce_sos2_with_binary(lambda_var) + assert len(eqs) == 2 + var_count = get_var_count_by_type(m) + assert var_count["binary"] == 1 + + for k in m.data: + sym = m.data[k] + if isinstance(sym, gp.Equation): + assert len(sym.domain) == 2 + print(sym.getDefinition()) + + def test_pwl_gray_code(): for n, m in [(2, 1), (3, 2), (4, 2), (5, 3), (8, 3), (513, 10), (700, 10)]: code = piecewise._generate_gray_code(n, m) From 31db32a4c2ea9fc922283bc149367983428d4907 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Thu, 19 Dec 2024 22:33:35 +0100 Subject: [PATCH 024/135] allow domains in piecewise_linear_function --- src/gamspy/formulations/piecewise.py | 95 +++++++++++++-------- tests/integration/models/piecewiseLinear.py | 21 +++++ tests/unit/test_formulation.py | 24 ++++++ 3 files changed, 105 insertions(+), 35 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index e1c8c111..20cef432 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -89,6 +89,47 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable) -> list[gp.Equation]: return equations +def _enforce_discontinuity( + lambda_var: gp.Variable, + combined_indices: typing.Sequence[int], +) -> list[gp.Equation]: + equations: list[gp.Equation] = [] + + len_x_points = len(lambda_var.domain[-1]) + previous_domains = lambda_var.domain[:-1] + + m = lambda_var.container + J, J2, SB = gp.math._generate_dims( + m, [len_x_points, len_x_points, len(combined_indices)] + ) + + J, J2, SB = gp.formulations.nn.utils._next_domains( + [J, J2, SB], previous_domains + ) + + di_param = [ + (str(i), str(j), str(j + 1)) for i, j in enumerate(combined_indices) + ] + + select_set = m.addSet(domain=[SB, J, J2], records=di_param) + select_var = m.addVariable(domain=[*previous_domains, SB], type="binary") + + select_equation = m.addEquation(domain=[*previous_domains, SB, J, J2]) + select_equation[[*previous_domains, select_set[SB, J, J2]]] = ( + lambda_var[[*previous_domains, J]] <= select_var[*previous_domains, SB] + ) + equations.append(select_equation) + + select_equation_2 = m.addEquation(domain=[*previous_domains, SB, J, J2]) + select_equation_2[[*previous_domains, select_set[SB, J, J2]]] = ( + lambda_var[[*previous_domains, J2]] + <= 1 - select_var[*previous_domains, SB] + ) + equations.append(select_equation_2) + + return equations + + def _check_points( x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], @@ -251,52 +292,52 @@ def piecewise_linear_function( x_points, y_points, discontinuous_indices, none_indices = _check_points( x_points, y_points ) - combined_indices = {*discontinuous_indices, *none_indices} + combined_indices = list({*discontinuous_indices, *none_indices}) m = input_x.container - out_y = m.addVariable() + input_domain = input_x.domain + out_y = m.addVariable(domain=input_domain) equations = [] - if len(combined_indices) > 0: - J, J2, SB = gp.math._generate_dims( - m, [len(x_points), len(x_points), len(combined_indices)] - ) - else: - J = gp.math._generate_dims(m, [len(x_points)])[0] - SB = None + J = gp.math._generate_dims(m, [len(x_points)])[0] + J = gp.formulations.nn.utils._next_domains([J], input_domain)[0] x_par = m.addParameter(domain=[J], records=np.array(x_points)) y_par = m.addParameter(domain=[J], records=np.array(y_points)) lambda_var = m.addVariable( - domain=[J], type="free" if using == "binary" else "sos2" + domain=[*input_domain, J], type="free" if using == "binary" else "sos2" ) lambda_var.lo[...] = 0 lambda_var.up[...] = 1 if not bound_domain: # lower bounds - lambda_var.lo[J].where[gp.Ord(J) == 2] = float("-inf") - lambda_var.lo[J].where[gp.Ord(J) == gp.Card(J) - 1] = float("-inf") + lambda_var.lo[*input_domain, J].where[gp.Ord(J) == 2] = float("-inf") + lambda_var.lo[*input_domain, J].where[gp.Ord(J) == gp.Card(J) - 1] = ( + float("-inf") + ) # upper bound - lambda_var.up[J].where[gp.Ord(J) == 1] = float("inf") - lambda_var.up[J].where[gp.Ord(J) == gp.Card(J)] = float("inf") + lambda_var.up[*input_domain, J].where[gp.Ord(J) == 1] = float("inf") + lambda_var.up[*input_domain, J].where[gp.Ord(J) == gp.Card(J)] = float( + "inf" + ) else: min_y = min(y_points) max_y = max(y_points) out_y.lo[...] = min_y out_y.up[...] = max_y - lambda_sum = m.addEquation() + lambda_sum = m.addEquation(domain=input_x.domain) lambda_sum[...] = gp.Sum(J, lambda_var) == 1 equations.append(lambda_sum) - set_x = m.addEquation() + set_x = m.addEquation(domain=input_x.domain) set_x[...] = input_x == gp.Sum(J, x_par * lambda_var) equations.append(set_x) - set_y = m.addEquation() + set_y = m.addEquation(domain=input_x.domain) set_y[...] = out_y == gp.Sum(J, y_par * lambda_var) equations.append(set_y) @@ -305,24 +346,8 @@ def piecewise_linear_function( equations.extend(extra_eqs) if len(combined_indices) > 0: - di_param = [ - (str(i), str(j), str(j + 1)) - for i, j in enumerate(combined_indices) - ] - select_set = m.addSet(domain=[SB, J, J2], records=di_param) - select_var = m.addVariable(domain=[SB], type="binary") - - select_equation = m.addEquation(domain=[SB, J, J2]) - select_equation[select_set[SB, J, J2]] = ( - lambda_var[J] <= select_var[SB] - ) - equations.append(select_equation) - - select_equation_2 = m.addEquation(domain=[SB, J, J2]) - select_equation_2[select_set[SB, J, J2]] = lambda_var[J2] <= ( - 1 - select_var[SB] - ) - equations.append(select_equation_2) + extra_eqs = _enforce_discontinuity(lambda_var, combined_indices) + equations.extend(extra_eqs) return out_y, equations diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index eceb43fb..ebdd75dd 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -186,6 +186,27 @@ def main(): assert y.toDense() == 45, "Case 14 failed !" print("Case 14 passed !") + # test piecewise_linear_function with a non-scalar input + i = gp.Set(m, name="i", records=["1", "2", "3", "4", "5"]) + x2 = gp.Variable(m, name="x2", domain=[i]) + x_points = [1, 4, None, 6, 10, 10, 20] + y_points = [1, 45, None, 30, 25, 30, 12] + y, eqs = gp.formulations.piecewise_linear_function(x2, x_points, y_points) + x2.fx["1"] = 1 + x2.fx["2"] = 2.5 + x2.fx["3"] = 8 + x2.fx["4"] = 4 + x2.fx["5"] = 15 + model = gp.Model( + m, + equations=eqs, + objective=gp.Sum(y.domain, y), + sense="max", + problem="mip", + ) + model.solve() + assert np.allclose(y.toDense(), np.array([1, 23, 27.5, 45, 21])) + if __name__ == "__main__": main() diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index a03de98b..6bb00d3c 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -18,11 +18,13 @@ def data(): m = gp.Container() x = gp.Variable(m, "x") + x2 = gp.Variable(m, "x2", domain=gp.math.dim([2, 4, 3])) x_points = [-10, 2.2, 5, 10] y_points = [10, 20, -2, -5] return { "m": m, "x": x, + "x2": x2, "x_points": x_points, "y_points": y_points, } @@ -89,6 +91,16 @@ def test_pwl_enforce_sos2_log_binary_with_domain_2(): print(sym.getDefinition()) +def test_pwl_enforce_discontinuity(): + m = gp.Container() + lambda_var = gp.Variable(m, name="lambda", domain=gp.math.dim([5, 5])) + # this will create binary variables + eqs = piecewise._enforce_discontinuity(lambda_var, [1, 3]) + assert len(eqs) == 2 + assert len(eqs[0].domain) == 4 + assert len(eqs[1].domain) == 4 + + def test_pwl_gray_code(): for n, m in [(2, 1), (3, 2), (4, 2), (5, 3), (8, 3), (513, 10), (700, 10)]: code = piecewise._generate_gray_code(n, m) @@ -146,6 +158,18 @@ def test_pwl_with_binary(data): assert len(eqs) == len(eqs2) +def test_pwl_with_domain(data): + x2 = data["x2"] + x_points = data["x_points"] + y_points = data["y_points"] + y, eqs = piecewise_linear_function(x2, x_points, y_points, using="binary") + y2, eqs2 = piecewise_linear_function(x2, x_points, y_points, using="sos2") + + assert len(y.domain) == len(x2.domain) + assert len(y2.domain) == len(x2.domain) + print(eqs[2].domain) + + def test_pwl_with_none(data): x = data["x"] x_points = [1, None, 2, 3] From 56a9a1d6cb026c2f3954ea7dfb9a9651864006c7 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sat, 21 Dec 2024 17:56:09 +0100 Subject: [PATCH 025/135] use 3.9 style unpacking in pwl for backwards compat --- src/gamspy/formulations/piecewise.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 20cef432..b22154ee 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -75,14 +75,14 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable) -> list[gp.Equation]: sos2_eq_1 = m.addEquation(domain=[*previous_domains, L]) sos2_eq_1[[*previous_domains, L]] = ( gp.Sum(use_set_1[L, J], lambda_var[[*previous_domains, J]]) - <= bin_var[*previous_domains, L] + <= bin_var[[*previous_domains, L]] ) equations.append(sos2_eq_1) sos2_eq_2 = m.addEquation(domain=[*previous_domains, L]) sos2_eq_2[[*previous_domains, L]] = ( gp.Sum(use_set_2[L, J], lambda_var[[*previous_domains, J]]) - <= 1 - bin_var[*previous_domains, L] + <= 1 - bin_var[[*previous_domains, L]] ) equations.append(sos2_eq_2) @@ -116,14 +116,15 @@ def _enforce_discontinuity( select_equation = m.addEquation(domain=[*previous_domains, SB, J, J2]) select_equation[[*previous_domains, select_set[SB, J, J2]]] = ( - lambda_var[[*previous_domains, J]] <= select_var[*previous_domains, SB] + lambda_var[[*previous_domains, J]] + <= select_var[[*previous_domains, SB]] ) equations.append(select_equation) select_equation_2 = m.addEquation(domain=[*previous_domains, SB, J, J2]) select_equation_2[[*previous_domains, select_set[SB, J, J2]]] = ( lambda_var[[*previous_domains, J2]] - <= 1 - select_var[*previous_domains, SB] + <= 1 - select_var[[*previous_domains, SB]] ) equations.append(select_equation_2) @@ -313,15 +314,15 @@ def piecewise_linear_function( lambda_var.up[...] = 1 if not bound_domain: # lower bounds - lambda_var.lo[*input_domain, J].where[gp.Ord(J) == 2] = float("-inf") - lambda_var.lo[*input_domain, J].where[gp.Ord(J) == gp.Card(J) - 1] = ( - float("-inf") - ) + lambda_var.lo[[*input_domain, J]].where[gp.Ord(J) == 2] = float("-inf") + lambda_var.lo[[*input_domain, J]].where[ + gp.Ord(J) == gp.Card(J) - 1 + ] = float("-inf") # upper bound - lambda_var.up[*input_domain, J].where[gp.Ord(J) == 1] = float("inf") - lambda_var.up[*input_domain, J].where[gp.Ord(J) == gp.Card(J)] = float( - "inf" + lambda_var.up[[*input_domain, J]].where[gp.Ord(J) == 1] = float("inf") + lambda_var.up[[*input_domain, J]].where[gp.Ord(J) == gp.Card(J)] = ( + float("inf") ) else: min_y = min(y_points) From 83f34ef90ef6a775d8e08ea5ff3baf92f66de235 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Sun, 22 Dec 2024 19:32:09 +0300 Subject: [PATCH 026/135] Lower the number of dices in the interrupt test and put a time limit to the solve. With 7 dices, the test takes between 20-60 secs depending on the platform. It should be interrupted with another thread way earlier but in case it doesn't work, the solve should finish because of the resource limit which is set to 60 secs. --- CHANGELOG.md | 5 +++++ tests/integration/test_solve.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0e91db..68222556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ GAMSPy CHANGELOG ================ +GAMSPy 1.4.1 +------------ +- Testing + - Lower the number of dices in the interrupt test and put a time limit to the solve. + GAMSPy 1.4.0 ------------ - General diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index 6192b033..cbf0c1a0 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -790,7 +790,7 @@ def test_interrupt(): m, name="dice", description="number of dice", - records=[f"dice{idx}" for idx in range(1, 10)], + records=[f"dice{idx}" for idx in range(1, 7)], ) flo = Parameter(m, name="flo", description="lowest face value", records=1) @@ -858,7 +858,7 @@ def interrupt_gams(model): thread = threading.Thread(target=interrupt_gams, args=(xdice,)) thread.start() - xdice.solve(output=sys.stdout) + xdice.solve(output=sys.stdout, options=Options(time_limit=60)) assert xdice.objective_value is not None assert xdice.solve_status == SolveStatus.UserInterrupt thread.join() From af7408f6262cc1f3976055a7f6d1d9e95157df3c Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 23 Dec 2024 14:43:47 +0100 Subject: [PATCH 027/135] Fix implicit parameter validation bug. --- CHANGELOG.md | 2 ++ src/gamspy/_algebra/operation.py | 4 ++-- src/gamspy/_symbols/implicits/implicit_parameter.py | 2 +- tests/integration/test_solve.py | 12 ++++++++++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68222556..12f3fa44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ GAMSPy CHANGELOG GAMSPy 1.4.1 ------------ +- General + - Fix implicit parameter validation bug. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. diff --git a/src/gamspy/_algebra/operation.py b/src/gamspy/_algebra/operation.py index 169f4bc8..df1c9d56 100644 --- a/src/gamspy/_algebra/operation.py +++ b/src/gamspy/_algebra/operation.py @@ -119,9 +119,9 @@ def _validate_operation( stack = control_stack + self.raw_domain if isinstance(self.rhs, expression.Expression): - self.rhs._validate_definition(stack) + self.rhs._validate_definition(utils._unpack(stack)) elif isinstance(self.rhs, Operation): - self.rhs._validate_operation(stack) + self.rhs._validate_operation(utils._unpack(stack)) def _get_index_str(self) -> str: if len(self.op_domain) == 1: diff --git a/src/gamspy/_symbols/implicits/implicit_parameter.py b/src/gamspy/_symbols/implicits/implicit_parameter.py index d4b8647e..1cfbca8f 100644 --- a/src/gamspy/_symbols/implicits/implicit_parameter.py +++ b/src/gamspy/_symbols/implicits/implicit_parameter.py @@ -105,7 +105,7 @@ def __setitem__(self, indices: Iterable | str, rhs: Expression) -> None: rhs, ) - statement._validate_definition(domain) + statement._validate_definition(utils._unpack(domain)) self.container._add_statement(statement) self.parent._assignment = statement diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index cbf0c1a0..4bc12c92 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -1132,6 +1132,18 @@ def test_validation_2(): ) +def test_validation_3(): + m = Container() + + v = Set(m, "v") + k = Set(m, "k") + z = Variable(m, "z", domain=[v, k]) + vk = Set(m, "vk", domain=[v, k]) + n = Parameter(m, domain=k) + + z.up[vk[v, k]] = n[k] + + def test_after_exception(data): m, *_ = data x = Variable(m, "x", type="positive") From 7a8d2345eaeb2a2c790d91c874f96e0817793a70 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Tue, 24 Dec 2024 15:04:19 +0300 Subject: [PATCH 028/135] add missing validation for operations where the rhs is an implicit symbol. --- src/gamspy/_algebra/operation.py | 11 +++++++++-- src/gamspy/_symbols/implicits/__init__.py | 2 ++ src/gamspy/_symbols/implicits/implicit_parameter.py | 1 + tests/integration/test_solve.py | 8 ++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/gamspy/_algebra/operation.py b/src/gamspy/_algebra/operation.py index df1c9d56..6703f122 100644 --- a/src/gamspy/_algebra/operation.py +++ b/src/gamspy/_algebra/operation.py @@ -117,11 +117,18 @@ def _validate_operation( if elem in control_stack: raise ValidationError(f"Set {elem} is already in control") + print(f"{control_stack=}, {self.raw_domain=}") stack = control_stack + self.raw_domain if isinstance(self.rhs, expression.Expression): - self.rhs._validate_definition(utils._unpack(stack)) + self.rhs._validate_definition(stack) elif isinstance(self.rhs, Operation): - self.rhs._validate_operation(utils._unpack(stack)) + self.rhs._validate_operation(stack) + elif isinstance(self.rhs, implicits.ImplicitSymbol): + for elem in self.rhs.domain: + if elem not in stack: + raise ValidationError( + f"Uncontrolled set `{elem}` entered as constant" + ) def _get_index_str(self) -> str: if len(self.op_domain) == 1: diff --git a/src/gamspy/_symbols/implicits/__init__.py b/src/gamspy/_symbols/implicits/__init__.py index e9f4c358..4bf3e540 100644 --- a/src/gamspy/_symbols/implicits/__init__.py +++ b/src/gamspy/_symbols/implicits/__init__.py @@ -1,11 +1,13 @@ from gamspy._symbols.implicits.implicit_equation import ImplicitEquation from gamspy._symbols.implicits.implicit_parameter import ImplicitParameter from gamspy._symbols.implicits.implicit_set import ImplicitSet +from gamspy._symbols.implicits.implicit_symbol import ImplicitSymbol from gamspy._symbols.implicits.implicit_variable import ImplicitVariable __all__ = [ "ImplicitEquation", "ImplicitParameter", "ImplicitSet", + "ImplicitSymbol", "ImplicitVariable", ] diff --git a/src/gamspy/_symbols/implicits/implicit_parameter.py b/src/gamspy/_symbols/implicits/implicit_parameter.py index 1cfbca8f..7bfadd02 100644 --- a/src/gamspy/_symbols/implicits/implicit_parameter.py +++ b/src/gamspy/_symbols/implicits/implicit_parameter.py @@ -105,6 +105,7 @@ def __setitem__(self, indices: Iterable | str, rhs: Expression) -> None: rhs, ) + print(f"{domain=}") statement._validate_definition(utils._unpack(domain)) self.container._add_statement(statement) diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index 4bc12c92..b7b7bec1 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -1135,14 +1135,22 @@ def test_validation_2(): def test_validation_3(): m = Container() + i = Set(m, "i") + j = Set(m, "j") v = Set(m, "v") k = Set(m, "k") z = Variable(m, "z", domain=[v, k]) vk = Set(m, "vk", domain=[v, k]) n = Parameter(m, domain=k) + a = Variable(m, "a", domain=i) z.up[vk[v, k]] = n[k] + with pytest.raises(ValidationError): + a.up[i] = Sum(vk, Sum(j, n[k])) + + a.up[i] = Sum(vk[v, k], Sum(j, n[k])) + def test_after_exception(data): m, *_ = data From 1f32f9c71f0f65ee56e1d39f82687d1d9210dc29 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Tue, 24 Dec 2024 15:13:49 +0300 Subject: [PATCH 029/135] remove print --- src/gamspy/_algebra/operation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gamspy/_algebra/operation.py b/src/gamspy/_algebra/operation.py index 6703f122..ed206e98 100644 --- a/src/gamspy/_algebra/operation.py +++ b/src/gamspy/_algebra/operation.py @@ -117,7 +117,6 @@ def _validate_operation( if elem in control_stack: raise ValidationError(f"Set {elem} is already in control") - print(f"{control_stack=}, {self.raw_domain=}") stack = control_stack + self.raw_domain if isinstance(self.rhs, expression.Expression): self.rhs._validate_definition(stack) From 739876fe19a9235873c67f8a99483d9f16ada13b Mon Sep 17 00:00:00 2001 From: msoyturk Date: Tue, 24 Dec 2024 15:52:36 +0300 Subject: [PATCH 030/135] Revert "add missing validation for operations where the rhs is an implicit symbol." This reverts commit 7a8d2345eaeb2a2c790d91c874f96e0817793a70. --- src/gamspy/_algebra/operation.py | 10 ++-------- src/gamspy/_symbols/implicits/__init__.py | 2 -- src/gamspy/_symbols/implicits/implicit_parameter.py | 1 - tests/integration/test_solve.py | 8 -------- 4 files changed, 2 insertions(+), 19 deletions(-) diff --git a/src/gamspy/_algebra/operation.py b/src/gamspy/_algebra/operation.py index ed206e98..df1c9d56 100644 --- a/src/gamspy/_algebra/operation.py +++ b/src/gamspy/_algebra/operation.py @@ -119,15 +119,9 @@ def _validate_operation( stack = control_stack + self.raw_domain if isinstance(self.rhs, expression.Expression): - self.rhs._validate_definition(stack) + self.rhs._validate_definition(utils._unpack(stack)) elif isinstance(self.rhs, Operation): - self.rhs._validate_operation(stack) - elif isinstance(self.rhs, implicits.ImplicitSymbol): - for elem in self.rhs.domain: - if elem not in stack: - raise ValidationError( - f"Uncontrolled set `{elem}` entered as constant" - ) + self.rhs._validate_operation(utils._unpack(stack)) def _get_index_str(self) -> str: if len(self.op_domain) == 1: diff --git a/src/gamspy/_symbols/implicits/__init__.py b/src/gamspy/_symbols/implicits/__init__.py index 4bf3e540..e9f4c358 100644 --- a/src/gamspy/_symbols/implicits/__init__.py +++ b/src/gamspy/_symbols/implicits/__init__.py @@ -1,13 +1,11 @@ from gamspy._symbols.implicits.implicit_equation import ImplicitEquation from gamspy._symbols.implicits.implicit_parameter import ImplicitParameter from gamspy._symbols.implicits.implicit_set import ImplicitSet -from gamspy._symbols.implicits.implicit_symbol import ImplicitSymbol from gamspy._symbols.implicits.implicit_variable import ImplicitVariable __all__ = [ "ImplicitEquation", "ImplicitParameter", "ImplicitSet", - "ImplicitSymbol", "ImplicitVariable", ] diff --git a/src/gamspy/_symbols/implicits/implicit_parameter.py b/src/gamspy/_symbols/implicits/implicit_parameter.py index 7bfadd02..1cfbca8f 100644 --- a/src/gamspy/_symbols/implicits/implicit_parameter.py +++ b/src/gamspy/_symbols/implicits/implicit_parameter.py @@ -105,7 +105,6 @@ def __setitem__(self, indices: Iterable | str, rhs: Expression) -> None: rhs, ) - print(f"{domain=}") statement._validate_definition(utils._unpack(domain)) self.container._add_statement(statement) diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index b7b7bec1..4bc12c92 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -1135,22 +1135,14 @@ def test_validation_2(): def test_validation_3(): m = Container() - i = Set(m, "i") - j = Set(m, "j") v = Set(m, "v") k = Set(m, "k") z = Variable(m, "z", domain=[v, k]) vk = Set(m, "vk", domain=[v, k]) n = Parameter(m, domain=k) - a = Variable(m, "a", domain=i) z.up[vk[v, k]] = n[k] - with pytest.raises(ValidationError): - a.up[i] = Sum(vk, Sum(j, n[k])) - - a.up[i] = Sum(vk[v, k], Sum(j, n[k])) - def test_after_exception(data): m, *_ = data From b4037aacbd7011ea81b6a79b0821705f8afdc6cd Mon Sep 17 00:00:00 2001 From: aalqershi Date: Thu, 26 Dec 2024 17:34:40 +0300 Subject: [PATCH 031/135] support bound propagation for flatten --- src/gamspy/formulations/shape.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index 5bd33e49..de9fb030 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -6,6 +6,7 @@ import gamspy as gp import gamspy.formulations.nn.utils as utils from gamspy.exceptions import ValidationError +from gamspy.math import dim def _get_new_domain( @@ -50,9 +51,18 @@ def _generate_index_matching_statement( domains_str = ",".join([x.name for x in domains]) return base_txt.format(matching_set.name, domains_str, flattened.name) +def _propagate_bounds(x, out): + m = x.container + bounds = m.addParameter(domain=dim([2, *x.shape])) + bounds[("0",) + tuple(x.domain)] = x.lo[...] + bounds[("1",) + tuple(x.domain)] = x.up[...] + + new_bounds = m.addParameter(domain=dim([2, *out.shape]), records=bounds.toDense().reshape((2,) + out.shape)) + out.lo[...] = new_bounds[("0",) + tuple(out.domain)] + out.up[...] = new_bounds[("1",) + tuple(out.domain)] def _flatten_dims_var( - x: gp.Variable, dims: list[int] + x: gp.Variable, dims: list[int], propagate_bounds: bool = True ) -> tuple[gp.Variable, list[gp.Equation]]: m = x.container new_domain, flattened = _get_new_domain(x, dims) @@ -60,6 +70,10 @@ def _flatten_dims_var( out = m.addVariable( domain=new_domain ) # outputs domain nearly matches the input domain + + if propagate_bounds and x.records is not None: + _propagate_bounds(x, out) + # match the flattened set to correct dims forwarded_domain = utils._next_domains([flattened, *x.domain], []) doms_to_flatten = [forwarded_domain[d + 1] for d in dims] @@ -85,7 +99,7 @@ def _flatten_dims_var( def flatten_dims( - x: gp.Variable | gp.Parameter, dims: list[int] + x: gp.Variable | gp.Parameter, dims: list[int], propagate_bounds: bool = True ) -> tuple[gp.Parameter | gp.Variable, list[gp.Equation]]: """ Flatten domains indicated by `dims` into a single domain. @@ -97,6 +111,8 @@ def flatten_dims( dims: list[int] List of integers indicating indices of the domains to be flattened. Must be consecutive indices. + propagate_bounds: bool, optional + Propagate bounds from the input to the output variable. Default is True. Examples -------- @@ -114,6 +130,9 @@ def flatten_dims( if not isinstance(x, (gp.Parameter, gp.Variable)): raise ValidationError("Expected a parameter or a variable input") + if not isinstance(propagate_bounds, bool): + raise ValidationError("Expected a boolean for propagate_bounds") + if len(dims) < 2: raise ValidationError("Expected at least 2 items in the dim array") @@ -141,4 +160,4 @@ def flatten_dims( if isinstance(x, gp.Parameter): return _flatten_dims_par(x, dims) - return _flatten_dims_var(x, dims) + return _flatten_dims_var(x, dims, propagate_bounds) From 311342d6b5e06327f06acc08eb584a454e4ffc7f Mon Sep 17 00:00:00 2001 From: aalqershi Date: Thu, 26 Dec 2024 17:38:56 +0300 Subject: [PATCH 032/135] format with pre-commit --- src/gamspy/formulations/shape.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index de9fb030..f5364454 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -51,16 +51,21 @@ def _generate_index_matching_statement( domains_str = ",".join([x.name for x in domains]) return base_txt.format(matching_set.name, domains_str, flattened.name) + def _propagate_bounds(x, out): m = x.container bounds = m.addParameter(domain=dim([2, *x.shape])) bounds[("0",) + tuple(x.domain)] = x.lo[...] bounds[("1",) + tuple(x.domain)] = x.up[...] - new_bounds = m.addParameter(domain=dim([2, *out.shape]), records=bounds.toDense().reshape((2,) + out.shape)) + new_bounds = m.addParameter( + domain=dim([2, *out.shape]), + records=bounds.toDense().reshape((2,) + out.shape), + ) out.lo[...] = new_bounds[("0",) + tuple(out.domain)] out.up[...] = new_bounds[("1",) + tuple(out.domain)] + def _flatten_dims_var( x: gp.Variable, dims: list[int], propagate_bounds: bool = True ) -> tuple[gp.Variable, list[gp.Equation]]: @@ -99,7 +104,9 @@ def _flatten_dims_var( def flatten_dims( - x: gp.Variable | gp.Parameter, dims: list[int], propagate_bounds: bool = True + x: gp.Variable | gp.Parameter, + dims: list[int], + propagate_bounds: bool = True, ) -> tuple[gp.Parameter | gp.Variable, list[gp.Equation]]: """ Flatten domains indicated by `dims` into a single domain. From f95e72ee77d687b2ba37991c48a0f5d8af617b34 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Thu, 26 Dec 2024 17:47:12 +0300 Subject: [PATCH 033/135] simplify --- src/gamspy/formulations/shape.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index f5364454..7c44f7f9 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -58,10 +58,10 @@ def _propagate_bounds(x, out): bounds[("0",) + tuple(x.domain)] = x.lo[...] bounds[("1",) + tuple(x.domain)] = x.up[...] - new_bounds = m.addParameter( - domain=dim([2, *out.shape]), - records=bounds.toDense().reshape((2,) + out.shape), - ) + nb_dom = dim([2, *out.shape]) + nb_data = bounds.toDense().reshape((2,) + out.shape) + + new_bounds = m.addParameter(domain=nb_dom, records=nb_data) out.lo[...] = new_bounds[("0",) + tuple(out.domain)] out.up[...] = new_bounds[("1",) + tuple(out.domain)] From 2d0924b9e734db8d711fbce1cd138d848aa70e14 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Thu, 26 Dec 2024 17:16:32 +0100 Subject: [PATCH 034/135] refactor pwl to pwl convexity to be more specific --- src/gamspy/formulations/__init__.py | 8 +-- src/gamspy/formulations/piecewise.py | 60 ++-------------- tests/integration/models/piecewiseLinear.py | 38 ++++++---- tests/unit/test_formulation.py | 78 ++++++++++++--------- 4 files changed, 77 insertions(+), 107 deletions(-) diff --git a/src/gamspy/formulations/__init__.py b/src/gamspy/formulations/__init__.py index c921050c..000e0bf4 100644 --- a/src/gamspy/formulations/__init__.py +++ b/src/gamspy/formulations/__init__.py @@ -7,9 +7,7 @@ MinPool2d, ) from gamspy.formulations.piecewise import ( - piecewise_linear_function, - piecewise_linear_function_with_binary, - piecewise_linear_function_with_sos2, + piecewise_linear_function_convexity_formulation, ) from gamspy.formulations.shape import flatten_dims @@ -21,7 +19,5 @@ "AvgPool2d", "Linear", "flatten_dims", - "piecewise_linear_function", - "piecewise_linear_function_with_sos2", - "piecewise_linear_function_with_binary", + "piecewise_linear_function_convexity_formulation", ] diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index b22154ee..40ee592b 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -205,7 +205,7 @@ def _check_points( return return_x, return_y, discontinuous_indices, none_indices -def piecewise_linear_function( +def piecewise_linear_function_convexity_formulation( input_x: gp.Variable, x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], @@ -213,11 +213,10 @@ def piecewise_linear_function( bound_domain: bool = True, ) -> tuple[gp.Variable, list[gp.Equation]]: """ - This function implements a piecewise linear function. Given an input - (independent) variable `input_x`, along with the defining `x_points` and - corresponding `y_points` of the piecewise function, it constructs the - dependent variable `y` and formulates the equations necessary to define the - function. + This function implements a piecewise linear function using the convexity formulation. + Given an input (independent) variable `input_x`, along with the defining `x_points` + and corresponding `y_points` of the piecewise function, it constructs the dependent + variable `y` and formulates the equations necessary to define the function. Internally, the function uses binary variables by default. If preferred, you can switch to SOS2 (Special Ordered Set Type 2) by setting the `using` @@ -351,52 +350,3 @@ def piecewise_linear_function( equations.extend(extra_eqs) return out_y, equations - - -def piecewise_linear_function_with_binary( - input_x: gp.Variable, - x_points: typing.Sequence[int | float], - y_points: typing.Sequence[int | float], -) -> tuple[gp.Variable, list[gp.Equation]]: - """ - Calls the piecewise_linear_function setting `using` keyword argument - to `binary` - - Parameters - ---------- - x : gp.Variable - Independent variable of the piecewise linear function - x_points: typing.Sequence[int | float] - Break points of the piecewise linear function in the x-axis - y_points: typing.Sequence[int| float] - Break points of the piecewise linear function in the y-axis - - """ - return piecewise_linear_function( - input_x, x_points, y_points, using="binary" - ) - - -def piecewise_linear_function_with_sos2( - input_x: gp.Variable, - x_points: typing.Sequence[int | float], - y_points: typing.Sequence[int | float], - bound_domain: bool = True, -) -> tuple[gp.Variable, list[gp.Equation]]: - """ - Calls the piecewise_linear_function setting `using` keyword argument - to `sos2`. - - Parameters - ---------- - x : gp.Variable - Independent variable of the piecewise linear function - x_points: typing.Sequence[int | float] - Break points of the piecewise linear function in the x-axis - y_points: typing.Sequence[int| float] - Break points of the piecewise linear function in the y-axis - - """ - return piecewise_linear_function( - input_x, x_points, y_points, using="sos2", bound_domain=bound_domain - ) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index ebdd75dd..b0ff9d29 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -72,11 +72,13 @@ def main(): ("max", exp_max, x_at_max), ]: for using in ["sos2", "binary"]: - y, eqs = gp.formulations.piecewise_linear_function( - x, - x_points, - y_points, - using=using, + y, eqs = ( + gp.formulations.piecewise_linear_function_convexity_formulation( + x, + x_points, + y_points, + using=using, + ) ) model = gp.Model( m, equations=eqs, objective=y, sense=sense, problem="mip" @@ -91,7 +93,7 @@ def main(): # y is not bounded x_points = [-4, -2, 1, 3] y_points = [-2, 0, 0, 2] - y, eqs = gp.formulations.piecewise_linear_function( + y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( x, x_points, y_points, using="sos2", bound_domain=False ) x.fx[...] = -5 @@ -108,7 +110,7 @@ def main(): # y is upper bounded x_points = [-4, -2, 1, 3] y_points = [-2, 0, 0, 0] - y, eqs = gp.formulations.piecewise_linear_function( + y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( x, x_points, y_points, using="sos2", bound_domain=False ) model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") @@ -123,7 +125,7 @@ def main(): # y is lower bounded x_points = [-4, -2, 1, 3] y_points = [-5, -5, 0, 2] - y, eqs = gp.formulations.piecewise_linear_function( + y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( x, x_points, y_points, using="sos2", bound_domain=False ) x.lo[...] = "-inf" @@ -140,7 +142,9 @@ def main(): # test discontinuous function not allowing in between value x_points = [1, 4, 4, 10] y_points = [1, 4, 8, 25] - y, eqs = gp.formulations.piecewise_linear_function(x, x_points, y_points) + y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( + x, x_points, y_points + ) x.fx[...] = 4 y.fx[...] = 6 # y can be either 4 or 8 but not their convex combination model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") @@ -153,7 +157,9 @@ def main(): # test None case x_points = [1, 4, None, 6, 10] y_points = [1, 4, None, 8, 25] - y, eqs = gp.formulations.piecewise_linear_function(x, x_points, y_points) + y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( + x, x_points, y_points + ) x.fx[...] = 5 # should be IntegerInfeasible since 5 \in [4, 6] model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") res = model.solve() @@ -165,7 +171,9 @@ def main(): # test None case x_points = [1, 4, None, 6, 10] y_points = [1, 4, None, 30, 25] - y, eqs = gp.formulations.piecewise_linear_function(x, x_points, y_points) + y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( + x, x_points, y_points + ) x.lo[...] = "-inf" x.up[...] = "inf" model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") @@ -177,7 +185,9 @@ def main(): # test None case x_points = [1, 4, None, 6, 10] y_points = [1, 45, None, 30, 25] - y, eqs = gp.formulations.piecewise_linear_function(x, x_points, y_points) + y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( + x, x_points, y_points + ) x.lo[...] = "-inf" x.up[...] = "inf" model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") @@ -191,7 +201,9 @@ def main(): x2 = gp.Variable(m, name="x2", domain=[i]) x_points = [1, 4, None, 6, 10, 10, 20] y_points = [1, 45, None, 30, 25, 30, 12] - y, eqs = gp.formulations.piecewise_linear_function(x2, x_points, y_points) + y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( + x2, x_points, y_points + ) x2.fx["1"] = 1 x2.fx["2"] = 2.5 x2.fx["3"] = 8 diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 6bb00d3c..ddbf0357 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -6,9 +6,7 @@ import gamspy.formulations.piecewise as piecewise from gamspy.exceptions import ValidationError from gamspy.formulations import ( - piecewise_linear_function, - piecewise_linear_function_with_binary, - piecewise_linear_function_with_sos2, + piecewise_linear_function_convexity_formulation, ) pytestmark = pytest.mark.unit @@ -125,10 +123,15 @@ def test_pwl_with_sos2(data): x = data["x"] x_points = data["x_points"] y_points = data["y_points"] - y, eqs = piecewise_linear_function_with_sos2(x, x_points, y_points) - y2, eqs2 = piecewise_linear_function(x, x_points, y_points, using="sos2") - y3, eqs2 = piecewise_linear_function_with_sos2( - x, x_points, y_points, bound_domain=False + y, eqs = piecewise_linear_function_convexity_formulation( + x, x_points, y_points, using="sos2" + ) + y2, eqs2 = piecewise_linear_function_convexity_formulation( + x, + x_points, + y_points, + bound_domain=False, + using="sos2", ) # there should be no binary variables @@ -137,7 +140,6 @@ def test_pwl_with_sos2(data): assert var_count["sos2"] == 3 # since we called it twice assert y.type == "free" assert y2.type == "free" - assert y3.type == "free" assert len(eqs) == len(eqs2) @@ -146,8 +148,12 @@ def test_pwl_with_binary(data): x = data["x"] x_points = data["x_points"] y_points = data["y_points"] - y, eqs = piecewise_linear_function_with_binary(x, x_points, y_points) - y2, eqs2 = piecewise_linear_function(x, x_points, y_points, using="binary") + y, eqs = piecewise_linear_function_convexity_formulation( + x, x_points, y_points, using="binary" + ) + y2, eqs2 = piecewise_linear_function_convexity_formulation( + x, x_points, y_points, using="binary" + ) # there should be no sos2 variables var_count = get_var_count_by_type(m) @@ -162,8 +168,12 @@ def test_pwl_with_domain(data): x2 = data["x2"] x_points = data["x_points"] y_points = data["y_points"] - y, eqs = piecewise_linear_function(x2, x_points, y_points, using="binary") - y2, eqs2 = piecewise_linear_function(x2, x_points, y_points, using="sos2") + y, eqs = piecewise_linear_function_convexity_formulation( + x2, x_points, y_points, using="binary" + ) + y2, eqs2 = piecewise_linear_function_convexity_formulation( + x2, x_points, y_points, using="sos2" + ) assert len(y.domain) == len(x2.domain) assert len(y2.domain) == len(x2.domain) @@ -174,7 +184,9 @@ def test_pwl_with_none(data): x = data["x"] x_points = [1, None, 2, 3] y_points = [10, None, 20, 45] - y, eqs = piecewise_linear_function(x, x_points, y_points) + y, eqs = piecewise_linear_function_convexity_formulation( + x, x_points, y_points + ) def test_pwl_validation(data): @@ -185,7 +197,7 @@ def test_pwl_validation(data): # incorrect using value pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, x_points, y_points, @@ -195,7 +207,7 @@ def test_pwl_validation(data): # x not a variable pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, 10, x_points, y_points, @@ -204,7 +216,7 @@ def test_pwl_validation(data): # incorrect x_points, y_points pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, 10, y_points, @@ -212,7 +224,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, x_points, 10, @@ -220,7 +232,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [1], [10], @@ -228,7 +240,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, x_points, [10], @@ -236,7 +248,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [*x_points, "a"], [*y_points, 5], @@ -244,7 +256,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [*x_points, 16], [*y_points, "a"], @@ -252,7 +264,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [3, 2, 1], [10, 20, 30], @@ -260,7 +272,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [3, 1, 2], [10, 20, 30], @@ -268,7 +280,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [1, 3, 2], [10, 20, 30], @@ -276,7 +288,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [1], [10], @@ -284,7 +296,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [1, 2, 3], [10, 20, 40], @@ -294,7 +306,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [None, 2, 3], [None, 20, 40], @@ -302,7 +314,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [2, 3, None], [20, 40, None], @@ -310,7 +322,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [None, 2, 3, None], [None, 20, 40, None], @@ -318,7 +330,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [0, None, 2, 3], [0, 10, 20, 40], @@ -326,7 +338,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [0, 1, 2, 3], [0, None, 20, 40], @@ -334,7 +346,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [1, None, None, 2, 3], [10, None, None, 20, 40], @@ -342,7 +354,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function, + piecewise_linear_function_convexity_formulation, x, [2, None, 2, 3], [10, None, 20, 40], From 6190d230b719df211d4ff82c095bfbf41e3e4f79 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Thu, 26 Dec 2024 17:47:13 +0100 Subject: [PATCH 035/135] update pwl api docs --- src/gamspy/formulations/piecewise.py | 35 ++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 40ee592b..9738d45a 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -29,7 +29,9 @@ def _generate_gray_code(n: int, n_bits: int) -> np.ndarray: def _enforce_sos2_with_binary(lambda_var: gp.Variable) -> list[gp.Equation]: """ - Enforces SOS2 constraints using binary variables. + Enforces SOS2 constraints using binary variables. This function is not suitable + for generic SOS2 implementation since it restricts the lambda_var values to be + between 0 and 1. However, it is usually faster than using SOS2 variables. Based on paper: `Modeling disjunctive constraints with a logarithmic number of binary variables and constraints @@ -218,10 +220,27 @@ def piecewise_linear_function_convexity_formulation( and corresponding `y_points` of the piecewise function, it constructs the dependent variable `y` and formulates the equations necessary to define the function. - Internally, the function uses binary variables by default. If preferred, - you can switch to SOS2 (Special Ordered Set Type 2) by setting the `using` - parameter to "sos2". `bound_domain` cannot be set to False while using - binary variable implementation. + Here is the convexity formulation: + + .. math:: + x = \sum_{i}{x\_points_i * \lambda_i} + + y = \sum_{i}{y\_points_i * \lambda_i} + + \sum_{i}{\lambda_i} = 1 + + \lambda_i \in SOS2 + + By default, SOS2 variables are implemented using binary variables. + See + `Modeling disjunctive constraints with a logarithmic number of binary variables and constraints + `_ + . + + Internally, the function employs binary variables as the default implementation. + However, you can switch to SOS2 (Special Ordered Set Type 2) by setting the `using` + parameter to `"sos2"`. Note that when using the binary variable implementation, the + `bound_domain` parameter must not be set to `False`. The implementation handles discontinuities in the function. To represent a discontinuity at a specific point `x_i`, include `x_i` twice in the `x_points` @@ -269,10 +288,12 @@ def piecewise_linear_function_convexity_formulation( Examples -------- >>> from gamspy import Container, Variable, Set - >>> from gamspy.formulations import piecewise_linear_function + >>> from gamspy.formulations import piecewise_linear_function_convexity_formulation >>> m = Container() >>> x = Variable(m, "x") - >>> y, eqs = piecewise_linear_function(x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37]) + >>> y, eqs = piecewise_linear_function_convexity_formulation( + >>> x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37] + >>> ) """ if using not in {"binary", "sos2"}: From 5cd30402cd726f8f15962987f8165fea91e621a0 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Thu, 26 Dec 2024 17:52:08 +0100 Subject: [PATCH 036/135] fix failing test --- tests/unit/test_formulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index ddbf0357..d776e13a 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -137,7 +137,7 @@ def test_pwl_with_sos2(data): # there should be no binary variables var_count = get_var_count_by_type(m) assert "binary" not in var_count - assert var_count["sos2"] == 3 # since we called it twice + assert var_count["sos2"] == 2 # since we called it twice assert y.type == "free" assert y2.type == "free" assert len(eqs) == len(eqs2) From 58ba9a61111637ab7e5c26817e326e9f721fb7c9 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Fri, 27 Dec 2024 16:32:49 +0100 Subject: [PATCH 037/135] fix failing docs --- src/gamspy/formulations/piecewise.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 9738d45a..877bffd8 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -291,9 +291,7 @@ def piecewise_linear_function_convexity_formulation( >>> from gamspy.formulations import piecewise_linear_function_convexity_formulation >>> m = Container() >>> x = Variable(m, "x") - >>> y, eqs = piecewise_linear_function_convexity_formulation( - >>> x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37] - >>> ) + >>> y, eqs = piecewise_linear_function_convexity_formulation(x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37]) """ if using not in {"binary", "sos2"}: From 577d0ff3217c09e1bfe687ea7c0e3d96d990ad6e Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 29 Dec 2024 15:01:35 +0100 Subject: [PATCH 038/135] add a draft version of indicator constraints --- src/gamspy/formulations/piecewise.py | 84 +++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 877bffd8..e41d7fbd 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -6,11 +6,11 @@ import numpy as np import gamspy as gp +from gamspy._symbols.implicits import ( + ImplicitVariable, +) from gamspy.exceptions import ValidationError -number = typing.Union[int, float] -linear_expression = typing.Union["gp.Expression", "gp.Variable", number] - def _generate_gray_code(n: int, n_bits: int) -> np.ndarray: """ @@ -207,6 +207,84 @@ def _check_points( return return_x, return_y, discontinuous_indices, none_indices +def _indicator( + indicator_var: gp.Variable, + indicator_val: typing.Literal[0, 1], + expr: gp.Expression, +) -> list[gp.Equation]: + # We will make this generic and public + if not isinstance(indicator_var, (gp.Variable, ImplicitVariable)): + raise ValidationError("indicator_var needs to be a variable") + + if indicator_var.type != "binary": + raise ValidationError("indicator_var needs to be a binary variable") + + if indicator_val != 0 and indicator_val != 1: + raise ValidationError("indicator_val needs to be 1 or 0") + + if not isinstance(expr, gp.Expression): + raise ValidationError("expr needs to be an expression") + + if expr.data not in {"=l=", "=e=", "=g="}: + raise ValidationError("expr needs to be inequality or equality") + + if len(expr.domain) != len(indicator_var.domain): + raise ValidationError( + "indicator_var and expr must have the same domain" + ) + + for i in range(len(expr.domain)): + if expr.domain[i] != indicator_var.domain[i]: + raise ValidationError( + "indicator_var and expr must have the same domain" + ) + + if expr.data in "=e=": + # sos1(bin_var, lhs - rhs) might be better + eqs1 = _indicator( + indicator_var, indicator_val, expr.left <= expr.right + ) + eqs2 = _indicator( + indicator_var, indicator_val, -expr.left <= -expr.right + ) + return [*eqs1, *eqs2] + + if expr.data == "=g=": + return _indicator( + indicator_var, indicator_val, -expr.left <= -expr.right + ) + + equations = [] + m = indicator_var.container + + slack_var = m.addVariable(domain=expr.domain, type="positive") + slack_eq = m.addEquation( + domain=expr.domain, definition=(expr.left - slack_var <= expr.right) + ) + equations.append(slack_eq) + + expr_domain = ... if len(expr.domain) == 0 else [*expr.domain] + + sos_dim = gp.math._generate_dims(m, [2])[0] + sos1_var = m.addVariable(domain=[*expr.domain, sos_dim], type="sos1") + sos1_eq_1 = m.addEquation(domain=expr.domain) + if indicator_val == 1: + sos1_eq_1[...] = ( + sos1_var[[*expr.domain, "0"]] == indicator_var[expr_domain] + ) + else: + sos1_eq_1[...] = ( + sos1_var[[*expr.domain, "0"]] == 1 - indicator_var[expr_domain] + ) + equations.append(sos1_eq_1) + + sos1_eq_2 = m.addEquation(domain=expr.domain) + sos1_eq_2[...] = sos1_var[[*expr.domain, "1"]] == slack_var[expr_domain] + equations.append(sos1_eq_2) + + return equations + + def piecewise_linear_function_convexity_formulation( input_x: gp.Variable, x_points: typing.Sequence[int | float], From e2bfef2b5b25a2cbe89652294703d2a6dfbd2c22 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 29 Dec 2024 17:18:13 +0100 Subject: [PATCH 039/135] in pwl convexity use sos1 for infs and do not enforce sos2 --- src/gamspy/formulations/piecewise.py | 70 ++++++++++++++++++---------- tests/unit/test_formulation.py | 10 ---- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index e41d7fbd..8d802c5d 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -285,6 +285,17 @@ def _indicator( return equations +def _generate_ray( + container: gp.Container, domain: list[gp.Set] +) -> tuple[gp.Variable, gp.Variable, list[gp.Equation]]: + # if b_var == 0 => x_var = 0 o.w x_var >= 0 + # effectively x_var <= bigM * b_var without bigM + x_var = container.addVariable(domain=domain, type="positive") + b_var = container.addVariable(domain=domain, type="binary") + eqs = _indicator(b_var, 0, x_var <= 0) + return x_var, b_var, eqs + + def piecewise_linear_function_convexity_formulation( input_x: gp.Variable, x_points: typing.Sequence[int | float], @@ -317,8 +328,7 @@ def piecewise_linear_function_convexity_formulation( Internally, the function employs binary variables as the default implementation. However, you can switch to SOS2 (Special Ordered Set Type 2) by setting the `using` - parameter to `"sos2"`. Note that when using the binary variable implementation, the - `bound_domain` parameter must not be set to `False`. + parameter to `"sos2"`. The implementation handles discontinuities in the function. To represent a discontinuity at a specific point `x_i`, include `x_i` twice in the `x_points` @@ -339,9 +349,9 @@ def piecewise_linear_function_convexity_formulation( of the using argument. The input variable `input_x` is restricted to the range defined by - `x_points` unless `bound_domain` is set to False. `bound_domain` can be set - to False only if using is "sos2". When `input_x` is not bound, you can assume - as if the first and the last line segments are extended. + `x_points` unless `bound_domain` is set to False. Setting `bound_domain` to True, + creates SOS1 type of variables independent from the `using` parameter. When `input_x` is + not bound, you can assume as if the first and the last line segments are extended. Returns the dependent variable `y` and the equations required to model the piecewise linear relationship. @@ -381,11 +391,6 @@ def piecewise_linear_function_convexity_formulation( if not isinstance(input_x, gp.Variable): raise ValidationError("input_x is expected to be a Variable") - if bound_domain is False and using == "binary": - raise ValidationError( - "bound_domain can only be false when using is sos2" - ) - x_points, y_points, discontinuous_indices, none_indices = _check_points( x_points, y_points ) @@ -406,36 +411,51 @@ def piecewise_linear_function_convexity_formulation( domain=[*input_domain, J], type="free" if using == "binary" else "sos2" ) + x_term = 0 + y_term = 0 lambda_var.lo[...] = 0 lambda_var.up[...] = 1 - if not bound_domain: - # lower bounds - lambda_var.lo[[*input_domain, J]].where[gp.Ord(J) == 2] = float("-inf") - lambda_var.lo[[*input_domain, J]].where[ - gp.Ord(J) == gp.Card(J) - 1 - ] = float("-inf") - - # upper bound - lambda_var.up[[*input_domain, J]].where[gp.Ord(J) == 1] = float("inf") - lambda_var.up[[*input_domain, J]].where[gp.Ord(J) == gp.Card(J)] = ( - float("inf") - ) - else: + if bound_domain: min_y = min(y_points) max_y = max(y_points) out_y.lo[...] = min_y out_y.up[...] = max_y + else: + x_neg_inf, b_neg_inf, eqs_neg_inf = _generate_ray(m, input_domain) + equations.extend(eqs_neg_inf) + + x_pos_inf, b_pos_inf, eqs_pos_inf = _generate_ray(m, input_domain) + equations.extend(eqs_pos_inf) + + pick_side = m.addEquation(domain=b_neg_inf.domain) + pick_side[...] = b_neg_inf + b_pos_inf <= 1 + equations.append(pick_side) + + limit_b_neg_inf = m.addEquation(domain=b_neg_inf.domain) + limit_b_neg_inf[...] = b_neg_inf <= lambda_var[[*input_domain, "0"]] + equations.append(limit_b_neg_inf) + + limit_b_pos_inf = m.addEquation(domain=b_pos_inf.domain) + last = str(len(J) - 1) + limit_b_pos_inf[...] = b_pos_inf <= lambda_var[[*input_domain, last]] + equations.append(limit_b_pos_inf) + + m_pos = (y_points[-1] - y_points[-2]) / (x_points[-1] - x_points[-2]) + m_neg = (y_points[0] - y_points[1]) / (x_points[0] - x_points[1]) + + x_term = x_pos_inf - x_neg_inf + y_term = (m_pos * x_pos_inf) - (m_neg * x_neg_inf) lambda_sum = m.addEquation(domain=input_x.domain) lambda_sum[...] = gp.Sum(J, lambda_var) == 1 equations.append(lambda_sum) set_x = m.addEquation(domain=input_x.domain) - set_x[...] = input_x == gp.Sum(J, x_par * lambda_var) + set_x[...] = input_x == gp.Sum(J, x_par * lambda_var) + x_term equations.append(set_x) set_y = m.addEquation(domain=input_x.domain) - set_y[...] = out_y == gp.Sum(J, y_par * lambda_var) + set_y[...] = out_y == gp.Sum(J, y_par * lambda_var) + y_term equations.append(set_y) if using == "binary": diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index d776e13a..624429bf 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -294,16 +294,6 @@ def test_pwl_validation(data): [10], ) - pytest.raises( - ValidationError, - piecewise_linear_function_convexity_formulation, - x, - [1, 2, 3], - [10, 20, 40], - using="binary", - bound_domain=False, - ) - pytest.raises( ValidationError, piecewise_linear_function_convexity_formulation, From 6f89620c39491f61ba239206d56d52e9878b6c40 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 29 Dec 2024 18:19:08 +0100 Subject: [PATCH 040/135] fix failing tests --- tests/unit/test_formulation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 624429bf..c3437ac9 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -136,11 +136,9 @@ def test_pwl_with_sos2(data): # there should be no binary variables var_count = get_var_count_by_type(m) - assert "binary" not in var_count assert var_count["sos2"] == 2 # since we called it twice assert y.type == "free" assert y2.type == "free" - assert len(eqs) == len(eqs2) def test_pwl_with_binary(data): From 50141d22defea516fef0b2e39b5f835f423c53c5 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 29 Dec 2024 18:50:44 +0100 Subject: [PATCH 041/135] extend tests --- src/gamspy/formulations/piecewise.py | 4 ++- tests/unit/test_formulation.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 8d802c5d..57643c50 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -41,6 +41,8 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable) -> list[gp.Equation]: m = lambda_var.container count_x = len(lambda_var.domain[-1]) # edge case + lambda_var.lo[...] = 0 + lambda_var.up[...] = 1 if count_x == 2: # if there are only 2 elements, it is already sos2 return equations @@ -234,7 +236,7 @@ def _indicator( ) for i in range(len(expr.domain)): - if expr.domain[i] != indicator_var.domain[i]: + if expr.domain[i].name != indicator_var.domain[i].name: raise ValidationError( "indicator_var and expr must have the same domain" ) diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index c3437ac9..569b963f 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -55,6 +55,52 @@ def test_pwl_enforce_sos2_log_binary(): assert var_count["binary"] == 1 +def test_pwl_enforce_sos2_log_binary_2(): + m = gp.Container() + i = gp.Set(m, name="i", records=["1", "2"]) + lambda_var = gp.Variable(m, name="lambda", domain=[i]) + # this will create binary variables + eqs = piecewise._enforce_sos2_with_binary(lambda_var) + assert len(eqs) == 0 + var_count = get_var_count_by_type(m) + assert "binary" not in var_count + + +def test_pwl_indicator(): + m = gp.Container() + i = gp.Set(m, name="i", records=["1", "2"]) + j = gp.Set(m, name="j", records=["1", "2", "3"]) + k = gp.Set(m, name="k", records=["a", "b"]) + + b = gp.Variable(m, name="b", type="binary", domain=[i]) + b2 = gp.Variable(m, name="b2", type="free", domain=[j]) + x = gp.Variable(m, name="x", domain=[i]) + x3 = gp.Variable(m, name="x3", domain=[k]) + + pytest.raises( + ValidationError, piecewise._indicator, "indicator_var", 0, x <= 10 + ) + + pytest.raises(ValidationError, piecewise._indicator, b2, 0, x <= 10) + pytest.raises(ValidationError, piecewise._indicator, b, -1, x <= 10) + pytest.raises(ValidationError, piecewise._indicator, b, 0, x) + pytest.raises(ValidationError, piecewise._indicator, b, 0, x + 10) + pytest.raises(ValidationError, piecewise._indicator, b, 0, x3 >= 10) + + eqs1 = piecewise._indicator(b, 0, x >= 10) + eqs2 = piecewise._indicator(b, 0, x <= 10) + eqs3 = piecewise._indicator(b, 0, x == 10) + assert len(eqs1) == len(eqs2) + assert len(eqs3) == len(eqs1) * 2 + + var_count = get_var_count_by_type(m) + assert "sos1" in var_count + + piecewise._indicator(b, 1, x >= 10) + piecewise._indicator(b, 1, x <= 10) + piecewise._indicator(b, 1, x == 10) + + def test_pwl_enforce_sos2_log_binary_with_domain(): m = gp.Container() j = gp.Set(m, name="j", records=["1", "2"]) From 91553665040db1f9e25d07c3cd824415fcd8010c Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 29 Dec 2024 19:27:09 +0100 Subject: [PATCH 042/135] extend integration tests for indicator constraints --- tests/integration/models/piecewiseLinear.py | 93 ++++++++++++++++++++- tests/unit/test_formulation.py | 2 + 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index b0ff9d29..f25feb24 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -17,10 +17,11 @@ import numpy as np import gamspy as gp +import gamspy.formulations.piecewise as piecewise -def main(): - print("Piecewise linear function test model") +def pwl_suite(): + print("PWL Suite") m = gp.Container() x = gp.Variable(m, name="x") @@ -218,7 +219,93 @@ def main(): ) model.solve() assert np.allclose(y.toDense(), np.array([1, 23, 27.5, 45, 21])) + m.close() + + +def indicator_suite(): + print("Indicator Suite") + m = gp.Container() + x = gp.Variable(m, name="x") + b = gp.Variable(m, name="b", type="binary") + + eqs1 = piecewise._indicator(b, 1, x <= 50) + model = gp.Model( + m, + equations=eqs1, + objective=x, + sense="max", + problem="mip", + ) + b.fx[...] = 1 + model.solve() + assert x.toDense() == 50, "Case 1 failed !" + print("Case 1 passed !") + + eqs2 = piecewise._indicator(b, 0, x <= 500) + b.lo[...] = 0 + b.up[...] = 1 + + model = gp.Model( + m, + equations=[*eqs1, *eqs2], + objective=x, + sense="max", + problem="mip", + ) + model.solve() + + assert x.toDense() == 500, "Case 2 failed !" + assert b.toDense() == 0, "Case 2 failed !" + print("Case 2 passed !") + + eqs3 = piecewise._indicator(b, 1, x <= 50) + model = gp.Model( + m, + equations=eqs3, + objective=x, + sense="max", + problem="mip", + ) + # b = 0 does not mean x cannot be less than 50 + b.fx[...] = 0 + x.fx[...] = 20 + model.solve() + assert x.toDense() == 20, "Case 3 failed !" + print("Case 3 passed !") + + eqs4 = piecewise._indicator(b, 1, x == 120) + model = gp.Model( + m, + equations=eqs4, + objective=x, + sense="min", + problem="mip", + ) + b.fx[...] = 1 + x.lo[...] = "-inf" + x.up[...] = "inf" + model.solve() + assert x.toDense() == 120, "Case 4 failed !" + print("Case 4 passed !") + + eqs5 = piecewise._indicator(b, 1, x >= 99) + model = gp.Model( + m, + equations=eqs5, + objective=x, + sense="min", + problem="mip", + ) + b.fx[...] = 1 + x.lo[...] = "-inf" + x.up[...] = "inf" + model.solve() + assert x.toDense() == 99, "Case 5 failed !" + print("Case 5 passed !") + m.close() if __name__ == "__main__": - main() + print("Piecewise linear function test model") + pwl_suite() + indicator_suite() diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 569b963f..abefb7be 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -75,6 +75,7 @@ def test_pwl_indicator(): b = gp.Variable(m, name="b", type="binary", domain=[i]) b2 = gp.Variable(m, name="b2", type="free", domain=[j]) x = gp.Variable(m, name="x", domain=[i]) + x2 = gp.Variable(m, name="x2", domain=[j]) x3 = gp.Variable(m, name="x3", domain=[k]) pytest.raises( @@ -86,6 +87,7 @@ def test_pwl_indicator(): pytest.raises(ValidationError, piecewise._indicator, b, 0, x) pytest.raises(ValidationError, piecewise._indicator, b, 0, x + 10) pytest.raises(ValidationError, piecewise._indicator, b, 0, x3 >= 10) + pytest.raises(ValidationError, piecewise._indicator, b, 0, x2 >= 10) eqs1 = piecewise._indicator(b, 0, x >= 10) eqs2 = piecewise._indicator(b, 0, x <= 10) From 892909672077fd07195c898b7033d4d2b00aff2f Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 29 Dec 2024 19:51:02 +0100 Subject: [PATCH 043/135] add first draft of pwl interval formulation --- src/gamspy/formulations/piecewise.py | 95 ++++++++++++++++++ tests/integration/models/piecewiseLinear.py | 105 +++++++++++--------- 2 files changed, 153 insertions(+), 47 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 57643c50..e0daba20 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -298,6 +298,98 @@ def _generate_ray( return x_var, b_var, eqs +def points_to_intervals( + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], +) -> dict[tuple[int | float, int | float], tuple[int | float, int | float]]: + result = {} + for i in range(len(x_points) - 1): + x1 = x_points[i] + x2 = x_points[i + 1] + y1 = y_points[i] + y2 = y_points[i + 1] + + slope = (y2 - y1) / (x2 - x1) + offset = y1 - (slope * x1) + result[(x1, x2)] = (slope, offset) + + return result + + +# TODO Missing docs, tests and support for discontinuities +def piecewise_linear_function_interval_formulation( + input_x: gp.Variable, + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], + bound_domain: bool = True, +) -> tuple[gp.Variable, list[gp.Equation]]: + if not isinstance(input_x, gp.Variable): + raise ValidationError("input_x is expected to be a Variable") + + if not isinstance(bound_domain, bool): + raise ValidationError("bound_domain is expected to be a boolean") + + x_points, y_points, discontinuous_indices, none_indices = _check_points( + x_points, y_points + ) + combined_indices = list({*discontinuous_indices, *none_indices}) + + if len(combined_indices) > 0: + raise ValidationError( + "This formulation does not support discontinuities" + ) + + equations = [] + + intervals = points_to_intervals(x_points, y_points) + lowerbounds_input = [(str(i), k[0]) for i, k in enumerate(intervals)] + upperbounds_input = [(str(i), k[1]) for i, k in enumerate(intervals)] + slopes_input = [(str(i), intervals[k][0]) for i, k in enumerate(intervals)] + offsets_input = [ + (str(i), intervals[k][1]) for i, k in enumerate(intervals) + ] + + input_domain = input_x.domain + m = input_x.container + + J = gp.math._generate_dims(m, [len(intervals)])[0] + J = gp.formulations.nn.utils._next_domains([J], input_domain)[0] + + lowerbounds = m.addParameter(domain=[J], records=lowerbounds_input) + upperbounds = m.addParameter(domain=[J], records=upperbounds_input) + slopes = m.addParameter(domain=[J], records=slopes_input) + offsets = m.addParameter(domain=[J], records=offsets_input) + bin_var = m.addVariable(domain=[*input_domain, J], type="binary") + + lambda_var = m.addVariable(domain=[*input_domain, J]) + + set_lambda_lowerbound = m.addEquation(domain=lambda_var.domain) + set_lambda_lowerbound[...] = lowerbounds * bin_var <= lambda_var + equations.append(set_lambda_lowerbound) + + set_lambda_upperbound = m.addEquation(domain=lambda_var.domain) + set_lambda_upperbound[...] = upperbounds * bin_var >= lambda_var + equations.append(set_lambda_upperbound) + + pick_one = m.addEquation(domain=input_domain) + pick_one[...] = gp.Sum(J, bin_var) == 1 + equations.append(pick_one) + + set_x = m.addEquation(domain=input_domain) + set_x[...] = input_x == gp.Sum(J, lambda_var) + equations.append(set_x) + + out_y = m.addVariable(domain=input_domain) + + set_y = m.addEquation(domain=input_domain) + set_y[...] = out_y == gp.Sum(J, lambda_var * slopes) + gp.Sum( + J, bin_var * offsets + ) + equations.append(set_y) + + return out_y, equations + + def piecewise_linear_function_convexity_formulation( input_x: gp.Variable, x_points: typing.Sequence[int | float], @@ -393,6 +485,9 @@ def piecewise_linear_function_convexity_formulation( if not isinstance(input_x, gp.Variable): raise ValidationError("input_x is expected to be a Variable") + if not isinstance(bound_domain, bool): + raise ValidationError("bound_domain is expected to be a boolean") + x_points, y_points, discontinuous_indices, none_indices = _check_points( x_points, y_points ) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index f25feb24..39cfcc9e 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -92,53 +92,64 @@ def pwl_suite(): # test bound cases # y is not bounded - x_points = [-4, -2, 1, 3] - y_points = [-2, 0, 0, 2] - y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( - x, x_points, y_points, using="sos2", bound_domain=False - ) - x.fx[...] = -5 - model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") - model.solve() - - assert math.isclose(y.toDense(), -3), "Case 5 failed !" - print("Case 5 passed !") - x.fx[...] = 100 - model.solve() - assert math.isclose(y.toDense(), 99), "Case 6 failed !" - print("Case 6 passed !") - - # y is upper bounded - x_points = [-4, -2, 1, 3] - y_points = [-2, 0, 0, 0] - y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( - x, x_points, y_points, using="sos2", bound_domain=False - ) - model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") - model.solve() - assert math.isclose(y.toDense(), 0), "Case 7 failed !" - print("Case 7 passed !") - x.fx[...] = 100 - model.solve() - assert math.isclose(y.toDense(), 0), "Case 8 failed !" - print("Case 8 passed !") - - # y is lower bounded - x_points = [-4, -2, 1, 3] - y_points = [-5, -5, 0, 2] - y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( - x, x_points, y_points, using="sos2", bound_domain=False - ) - x.lo[...] = "-inf" - x.up[...] = "inf" - model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") - model.solve() - assert math.isclose(y.toDense(), -5), "Case 9 failed !" - print("Case 9 passed !") - x.fx[...] = -100 - model.solve() - assert math.isclose(y.toDense(), -5), "Case 10 failed !" - print("Case 10 passed !") + for using in ["sos2", "binary"]: + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 2] + y, eqs = ( + gp.formulations.piecewise_linear_function_convexity_formulation( + x, x_points, y_points, using=using, bound_domain=False + ) + ) + x.fx[...] = -5 + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), -3), "Case 5 failed !" + print("Case 5 passed !") + x.fx[...] = 100 + model.solve() + assert math.isclose(y.toDense(), 99), "Case 6 failed !" + print("Case 6 passed !") + + # y is upper bounded + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 0] + y, eqs = ( + gp.formulations.piecewise_linear_function_convexity_formulation( + x, x_points, y_points, using=using, bound_domain=False + ) + ) + model = gp.Model( + m, equations=eqs, objective=y, sense="max", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), 0), "Case 7 failed !" + print("Case 7 passed !") + x.fx[...] = 100 + model.solve() + assert math.isclose(y.toDense(), 0), "Case 8 failed !" + print("Case 8 passed !") + # y is lower bounded + x_points = [-4, -2, 1, 3] + y_points = [-5, -5, 0, 2] + y, eqs = ( + gp.formulations.piecewise_linear_function_convexity_formulation( + x, x_points, y_points, using=using, bound_domain=False + ) + ) + x.lo[...] = "-inf" + x.up[...] = "inf" + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), -5), "Case 9 failed !" + print("Case 9 passed !") + x.fx[...] = -100 + model.solve() + assert math.isclose(y.toDense(), -5), "Case 10 failed !" + print("Case 10 passed !") # test discontinuous function not allowing in between value x_points = [1, 4, 4, 10] From 3ffe0f341625194750f519fdb749555dc99a4441 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Thu, 2 Jan 2025 09:17:43 +0100 Subject: [PATCH 044/135] Install dependencies in the first cell of the example transportation notebook. --- CHANGELOG.md | 2 + docs/user/notebooks/trnsport_colab.ipynb | 196 +++++++++++++---------- 2 files changed, 111 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f3fa44..44133c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ GAMSPy 1.4.1 - Fix implicit parameter validation bug. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. +- Documentation + - Install dependencies in the first cell of the example transportation notebook. GAMSPy 1.4.0 ------------ diff --git a/docs/user/notebooks/trnsport_colab.ipynb b/docs/user/notebooks/trnsport_colab.ipynb index ac6f5feb..8fd323c0 100644 --- a/docs/user/notebooks/trnsport_colab.ipynb +++ b/docs/user/notebooks/trnsport_colab.ipynb @@ -55,6 +55,26 @@ "cell_type": "code", "execution_count": 1, "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m23.0.1\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m24.3.1\u001b[0m\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n" + ] + } + ], + "source": [ + "# Install dependencies\n", + "! pip install -q gamspy" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, "outputs": [], "source": [ "from gamspy import Container, Set, Parameter, Variable, Equation, Model, Sum, Sense, Options" @@ -71,7 +91,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -94,7 +114,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -103,7 +123,7 @@ "('i', 'j')" ] }, - "execution_count": 3, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -123,16 +143,16 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'sa1c133b2_7bce_4c25_b49b_283facdc969c'" + "'sd7e7c25f_6b1f_4e57_87c6_2d9265e74262'" ] }, - "execution_count": 4, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -160,7 +180,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "metadata": {}, "outputs": [], "source": [ @@ -195,7 +215,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -231,7 +251,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ @@ -261,7 +281,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 9, "metadata": {}, "outputs": [], "source": [ @@ -277,7 +297,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 10, "metadata": {}, "outputs": [], "source": [ @@ -293,7 +313,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 11, "metadata": {}, "outputs": [ { @@ -336,7 +356,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ @@ -363,7 +383,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 13, "metadata": {}, "outputs": [], "source": [ @@ -387,7 +407,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -424,7 +444,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 15, "metadata": {}, "outputs": [ { @@ -473,7 +493,7 @@ "1 san-diego " ] }, - "execution_count": 14, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -508,7 +528,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -532,8 +552,8 @@ " \n", " \n", " \n", - " i\n", - " j\n", + " from\n", + " to\n", " value\n", " \n", " \n", @@ -579,7 +599,7 @@ "" ], "text/plain": [ - " i j value\n", + " from to value\n", "0 seattle new-york 2.5\n", "1 seattle chicago 1.7\n", "2 seattle topeka 1.8\n", @@ -588,7 +608,7 @@ "5 san-diego topeka 1.4" ] }, - "execution_count": 15, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -637,7 +657,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 17, "metadata": {}, "outputs": [ { @@ -661,8 +681,8 @@ " \n", " \n", " \n", - " i\n", - " j\n", + " from\n", + " to\n", " value\n", " \n", " \n", @@ -708,7 +728,7 @@ "" ], "text/plain": [ - " i j value\n", + " from to value\n", "0 seattle new-york 0.225\n", "1 seattle chicago 0.153\n", "2 seattle topeka 0.162\n", @@ -717,7 +737,7 @@ "5 san-diego topeka 0.126" ] }, - "execution_count": 16, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } @@ -738,7 +758,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 18, "metadata": {}, "outputs": [ { @@ -818,7 +838,7 @@ "5 san-diego topeka 0.126" ] }, - "execution_count": 17, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -839,44 +859,45 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 19, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "--- Job _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms Start 08/28/24 17:42:46 47.4.1 4b675771 LEX-LEG x86 64bit/Linux\n", + "--- Job _8a519651-4d31-457c-88b6-9bcb50a20b49.gms Start 01/02/25 09:15:04 48.5.0 5f05ac2f LEX-LEG x86 64bit/Linux\n", "--- Applying:\n", - " /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/gmsprmun.txt\n", + " /home/muhammet/Documents/gams_workspace/gamspy/venv/lib/python3.9/site-packages/gamspy_base/gmsprmun.txt\n", "--- GAMS Parameters defined\n", - " Input /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.gms\n", - " Output /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.lst\n", - " ScrDir /tmp/tmp_l67j_pp/tmp5owilr3v/\n", - " SysDir /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/\n", + " LP CPLEX\n", + " Input /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.gms\n", + " Output /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.lst\n", + " ScrDir /tmp/tmp8ab9them/tmpageeuohu/\n", + " SysDir /home/muhammet/Documents/gams_workspace/gamspy/venv/lib/python3.9/site-packages/gamspy_base/\n", " LogOption 3\n", - " Trace /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.txt\n", - " License /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/user_license.txt\n", - " OptDir /tmp/tmp_l67j_pp/\n", + " Trace /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.txt\n", + " License /home/muhammet/.local/share/GAMSPy/gamspy_license.txt\n", + " OptDir /tmp/tmp8ab9them/\n", " LimRow 0\n", " LimCol 0\n", " TraceOpt 3\n", - " GDX /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980out.gdx\n", + " GDX /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49out.gdx\n", " SolPrint 0\n", " PreviousWork 1\n", " gdxSymbols newOrChanged\n", "Licensee: GAMSPy Incremental Professional G240510+0003Cc-GEN\n", " GAMS DC0000\n", - " /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/user_license.txt\n", - " node:88412201 \n", - " Evaluation license: Not for commercial or production use\n", - " The evaluation period of the license will expire on May 14, 2029\n", + " /home/muhammet/.local/share/GAMSPy/gamspy_license.txt\n", + " node:88412201 v:2 \n", + " Time-limited license with GAMSPy usage\n", + " The expiration date of time-limited license is May 14, 2029\n", "Processor information: 1 socket(s), 12 core(s), and 16 thread(s) available\n", "--- Starting compilation\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(67) 4 Mb\n", + "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(68) 4 Mb\n", "--- Starting execution: elapsed 0:00:00.001\n", "--- Generating LP model transport\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(129) 4 Mb\n", + "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(130) 4 Mb\n", "--- 6 rows 7 columns 19 non-zeroes\n", "--- Range statistics (absolute non-zero finite values)\n", "--- RHS [min, max] : [ 2.750E+02, 6.000E+02] - Zero values observed as well\n", @@ -884,7 +905,7 @@ "--- Matrix [min, max] : [ 1.260E-01, 1.000E+00]\n", "--- Executing CPLEX (Solvelink=2): elapsed 0:00:00.002\n", "\n", - "IBM ILOG CPLEX 47.4.1 4b675771 Aug 13, 2024 LEG x86 64bit/Linux \n", + "IBM ILOG CPLEX 48.5.0 5f05ac2f Dec 20, 2024 LEG x86 64bit/Linux \n", "\n", "--- GAMS/CPLEX licensed for continuous and discrete problems.\n", "--- GMO setup time: 0.00s\n", @@ -918,11 +939,11 @@ "Objective: 153.675000\n", "\n", "--- Reading solution for model transport\n", - "--- Executing after solve: elapsed 0:00:00.015\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(191) 4 Mb\n", - "--- GDX File /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980out.gdx\n", + "--- Executing after solve: elapsed 0:00:00.014\n", + "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(192) 4 Mb\n", + "--- GDX File /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49out.gdx\n", "*** Status: Normal completion\n", - "--- Job _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms Stop 08/28/24 17:42:46 elapsed 0:00:00.015\n" + "--- Job _8a519651-4d31-457c-88b6-9bcb50a20b49.gms Stop 01/02/25 09:15:04 elapsed 0:00:00.014\n" ] }, { @@ -980,7 +1001,7 @@ "0 LP CPLEX 0 " ] }, - "execution_count": 18, + "execution_count": 19, "metadata": {}, "output_type": "execute_result" } @@ -1008,45 +1029,46 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 20, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(191) 4 Mb\n", - "--- Job _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms Start 08/28/24 17:42:46 47.4.1 4b675771 LEX-LEG x86 64bit/Linux\n", + "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(192) 4 Mb\n", + "--- Job _8a519651-4d31-457c-88b6-9bcb50a20b49.gms Start 01/02/25 09:15:04 48.5.0 5f05ac2f LEX-LEG x86 64bit/Linux\n", "--- Applying:\n", - " /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/gmsprmun.txt\n", + " /home/muhammet/Documents/gams_workspace/gamspy/venv/lib/python3.9/site-packages/gamspy_base/gmsprmun.txt\n", "--- GAMS Parameters defined\n", - " Input /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.gms\n", - " Output /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.lst\n", - " ScrDir /tmp/tmp_l67j_pp/tmp5owilr3v/\n", - " SysDir /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/\n", + " LP CPLEX\n", + " Input /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.gms\n", + " Output /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.lst\n", + " ScrDir /tmp/tmp8ab9them/tmpageeuohu/\n", + " SysDir /home/muhammet/Documents/gams_workspace/gamspy/venv/lib/python3.9/site-packages/gamspy_base/\n", " LogOption 3\n", - " Trace /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980.txt\n", - " License /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/user_license.txt\n", - " OptDir /tmp/tmp_l67j_pp/\n", + " Trace /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.txt\n", + " License /home/muhammet/.local/share/GAMSPy/gamspy_license.txt\n", + " OptDir /tmp/tmp8ab9them/\n", " LimRow 10\n", " LimCol 10\n", " TraceOpt 3\n", - " GDX /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980out.gdx\n", + " GDX /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49out.gdx\n", " SolPrint 0\n", " PreviousWork 1\n", " gdxSymbols newOrChanged\n", "Licensee: GAMSPy Incremental Professional G240510+0003Cc-GEN\n", " GAMS DC0000\n", - " /home/muhammet/anaconda3/envs/py38/lib/python3.8/site-packages/gamspy_base/user_license.txt\n", - " node:88412201 \n", - " Evaluation license: Not for commercial or production use\n", - " The evaluation period of the license will expire on May 14, 2029\n", + " /home/muhammet/.local/share/GAMSPy/gamspy_license.txt\n", + " node:88412201 v:2 \n", + " Time-limited license with GAMSPy usage\n", + " The expiration date of time-limited license is May 14, 2029\n", "Processor information: 1 socket(s), 12 core(s), and 16 thread(s) available\n", "--- Starting compilation\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(67) 4 Mb\n", + "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(68) 4 Mb\n", "--- Starting execution: elapsed 0:00:00.001\n", "--- Generating LP model transport\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(196) 4 Mb\n", + "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(198) 4 Mb\n", "--- 6 rows 7 columns 19 non-zeroes\n", "--- Range statistics (absolute non-zero finite values)\n", "--- RHS [min, max] : [ 2.750E+02, 6.000E+02] - Zero values observed as well\n", @@ -1054,7 +1076,7 @@ "--- Matrix [min, max] : [ 1.260E-01, 1.000E+00]\n", "--- Executing CPLEX (Solvelink=2): elapsed 0:00:00.001\n", "\n", - "IBM ILOG CPLEX 47.4.1 4b675771 Aug 13, 2024 LEG x86 64bit/Linux \n", + "IBM ILOG CPLEX 48.5.0 5f05ac2f Dec 20, 2024 LEG x86 64bit/Linux \n", "\n", "--- GAMS/CPLEX licensed for continuous and discrete problems.\n", "--- GMO setup time: 0.00s\n", @@ -1083,11 +1105,11 @@ "Objective: 153.675000\n", "\n", "--- Reading solution for model transport\n", - "--- Executing after solve: elapsed 0:00:00.013\n", - "--- _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms(258) 4 Mb\n", - "--- GDX File /tmp/tmp_l67j_pp/_b92462b7-3118-40e9-8598-7b3b1f6e3980out.gdx\n", + "--- Executing after solve: elapsed 0:00:00.012\n", + "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(260) 4 Mb\n", + "--- GDX File /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49out.gdx\n", "*** Status: Normal completion\n", - "--- Job _b92462b7-3118-40e9-8598-7b3b1f6e3980.gms Stop 08/28/24 17:42:46 elapsed 0:00:00.013\n" + "--- Job _8a519651-4d31-457c-88b6-9bcb50a20b49.gms Stop 01/02/25 09:15:04 elapsed 0:00:00.012\n" ] }, { @@ -1145,7 +1167,7 @@ "0 LP CPLEX 0 " ] }, - "execution_count": 19, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } @@ -1163,7 +1185,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 21, "metadata": {}, "outputs": [ { @@ -1185,7 +1207,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 22, "metadata": {}, "outputs": [ { @@ -1248,7 +1270,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 23, "metadata": {}, "outputs": [ { @@ -1266,7 +1288,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 24, "metadata": {}, "outputs": [ { @@ -1327,7 +1349,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 25, "metadata": {}, "outputs": [ { @@ -1435,7 +1457,7 @@ "5 san-diego topeka 275.0 0.000 0.0 inf 1.0" ] }, - "execution_count": 24, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -1454,7 +1476,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 26, "metadata": {}, "outputs": [ { @@ -1463,7 +1485,7 @@ "153.675" ] }, - "execution_count": 25, + "execution_count": 26, "metadata": {}, "output_type": "execute_result" } @@ -1483,7 +1505,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 27, "metadata": {}, "outputs": [ { @@ -1515,7 +1537,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 28, "metadata": {}, "outputs": [ { @@ -1547,7 +1569,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.19" + "version": "3.9.20" } }, "nbformat": 4, From e74a743e1b24b79761bbf1b815a6f065979104bc Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Thu, 2 Jan 2025 09:27:33 +0100 Subject: [PATCH 045/135] remove unnecessary warning message in the transport notebook --- docs/user/notebooks/trnsport_colab.ipynb | 90 +++++++++++------------- 1 file changed, 40 insertions(+), 50 deletions(-) diff --git a/docs/user/notebooks/trnsport_colab.ipynb b/docs/user/notebooks/trnsport_colab.ipynb index 8fd323c0..d0b1c4dd 100644 --- a/docs/user/notebooks/trnsport_colab.ipynb +++ b/docs/user/notebooks/trnsport_colab.ipynb @@ -55,17 +55,7 @@ "cell_type": "code", "execution_count": 1, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m23.0.1\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m24.3.1\u001b[0m\n", - "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n" - ] - } - ], + "outputs": [], "source": [ "# Install dependencies\n", "! pip install -q gamspy" @@ -149,7 +139,7 @@ { "data": { "text/plain": [ - "'sd7e7c25f_6b1f_4e57_87c6_2d9265e74262'" + "'sa36353c4_2966_42cb_8b58_cca86e453ff8'" ] }, "execution_count": 5, @@ -866,23 +856,23 @@ "name": "stdout", "output_type": "stream", "text": [ - "--- Job _8a519651-4d31-457c-88b6-9bcb50a20b49.gms Start 01/02/25 09:15:04 48.5.0 5f05ac2f LEX-LEG x86 64bit/Linux\n", + "--- Job _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms Start 01/02/25 09:26:25 48.5.0 5f05ac2f LEX-LEG x86 64bit/Linux\n", "--- Applying:\n", - " /home/muhammet/Documents/gams_workspace/gamspy/venv/lib/python3.9/site-packages/gamspy_base/gmsprmun.txt\n", + " /home/muhammet/anaconda3/envs/py313/lib/python3.13/site-packages/gamspy_base/gmsprmun.txt\n", "--- GAMS Parameters defined\n", " LP CPLEX\n", - " Input /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.gms\n", - " Output /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.lst\n", - " ScrDir /tmp/tmp8ab9them/tmpageeuohu/\n", - " SysDir /home/muhammet/Documents/gams_workspace/gamspy/venv/lib/python3.9/site-packages/gamspy_base/\n", + " Input /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms\n", + " Output /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.lst\n", + " ScrDir /tmp/tmpdie1ewav/tmp7px2lpec/\n", + " SysDir /home/muhammet/anaconda3/envs/py313/lib/python3.13/site-packages/gamspy_base/\n", " LogOption 3\n", - " Trace /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.txt\n", + " Trace /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.txt\n", " License /home/muhammet/.local/share/GAMSPy/gamspy_license.txt\n", - " OptDir /tmp/tmp8ab9them/\n", + " OptDir /tmp/tmpdie1ewav/\n", " LimRow 0\n", " LimCol 0\n", " TraceOpt 3\n", - " GDX /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49out.gdx\n", + " GDX /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfeout.gdx\n", " SolPrint 0\n", " PreviousWork 1\n", " gdxSymbols newOrChanged\n", @@ -894,16 +884,16 @@ " The expiration date of time-limited license is May 14, 2029\n", "Processor information: 1 socket(s), 12 core(s), and 16 thread(s) available\n", "--- Starting compilation\n", - "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(68) 4 Mb\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(68) 4 Mb\n", "--- Starting execution: elapsed 0:00:00.001\n", "--- Generating LP model transport\n", - "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(130) 4 Mb\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(130) 4 Mb\n", "--- 6 rows 7 columns 19 non-zeroes\n", "--- Range statistics (absolute non-zero finite values)\n", "--- RHS [min, max] : [ 2.750E+02, 6.000E+02] - Zero values observed as well\n", "--- Bound [min, max] : [ NA, NA] - Zero values observed as well\n", "--- Matrix [min, max] : [ 1.260E-01, 1.000E+00]\n", - "--- Executing CPLEX (Solvelink=2): elapsed 0:00:00.002\n", + "--- Executing CPLEX (Solvelink=2): elapsed 0:00:00.003\n", "\n", "IBM ILOG CPLEX 48.5.0 5f05ac2f Dec 20, 2024 LEG x86 64bit/Linux \n", "\n", @@ -939,11 +929,11 @@ "Objective: 153.675000\n", "\n", "--- Reading solution for model transport\n", - "--- Executing after solve: elapsed 0:00:00.014\n", - "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(192) 4 Mb\n", - "--- GDX File /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49out.gdx\n", + "--- Executing after solve: elapsed 0:00:00.040\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(192) 4 Mb\n", + "--- GDX File /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfeout.gdx\n", "*** Status: Normal completion\n", - "--- Job _8a519651-4d31-457c-88b6-9bcb50a20b49.gms Stop 01/02/25 09:15:04 elapsed 0:00:00.014\n" + "--- Job _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms Stop 01/02/25 09:26:25 elapsed 0:00:00.041\n" ] }, { @@ -987,7 +977,7 @@ " 7\n", " LP\n", " CPLEX\n", - " 0\n", + " 0.004\n", " \n", " \n", "\n", @@ -998,7 +988,7 @@ "0 Normal OptimalGlobal 153.675 6 7 \n", "\n", " Model Type Solver Solver Time \n", - "0 LP CPLEX 0 " + "0 LP CPLEX 0.004 " ] }, "execution_count": 19, @@ -1036,24 +1026,24 @@ "name": "stdout", "output_type": "stream", "text": [ - "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(192) 4 Mb\n", - "--- Job _8a519651-4d31-457c-88b6-9bcb50a20b49.gms Start 01/02/25 09:15:04 48.5.0 5f05ac2f LEX-LEG x86 64bit/Linux\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(192) 4 Mb\n", + "--- Job _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms Start 01/02/25 09:26:25 48.5.0 5f05ac2f LEX-LEG x86 64bit/Linux\n", "--- Applying:\n", - " /home/muhammet/Documents/gams_workspace/gamspy/venv/lib/python3.9/site-packages/gamspy_base/gmsprmun.txt\n", + " /home/muhammet/anaconda3/envs/py313/lib/python3.13/site-packages/gamspy_base/gmsprmun.txt\n", "--- GAMS Parameters defined\n", " LP CPLEX\n", - " Input /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.gms\n", - " Output /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.lst\n", - " ScrDir /tmp/tmp8ab9them/tmpageeuohu/\n", - " SysDir /home/muhammet/Documents/gams_workspace/gamspy/venv/lib/python3.9/site-packages/gamspy_base/\n", + " Input /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms\n", + " Output /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.lst\n", + " ScrDir /tmp/tmpdie1ewav/tmp7px2lpec/\n", + " SysDir /home/muhammet/anaconda3/envs/py313/lib/python3.13/site-packages/gamspy_base/\n", " LogOption 3\n", - " Trace /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49.txt\n", + " Trace /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfe.txt\n", " License /home/muhammet/.local/share/GAMSPy/gamspy_license.txt\n", - " OptDir /tmp/tmp8ab9them/\n", + " OptDir /tmp/tmpdie1ewav/\n", " LimRow 10\n", " LimCol 10\n", " TraceOpt 3\n", - " GDX /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49out.gdx\n", + " GDX /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfeout.gdx\n", " SolPrint 0\n", " PreviousWork 1\n", " gdxSymbols newOrChanged\n", @@ -1065,16 +1055,16 @@ " The expiration date of time-limited license is May 14, 2029\n", "Processor information: 1 socket(s), 12 core(s), and 16 thread(s) available\n", "--- Starting compilation\n", - "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(68) 4 Mb\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(68) 4 Mb\n", "--- Starting execution: elapsed 0:00:00.001\n", "--- Generating LP model transport\n", - "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(198) 4 Mb\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(198) 4 Mb\n", "--- 6 rows 7 columns 19 non-zeroes\n", "--- Range statistics (absolute non-zero finite values)\n", "--- RHS [min, max] : [ 2.750E+02, 6.000E+02] - Zero values observed as well\n", "--- Bound [min, max] : [ NA, NA] - Zero values observed as well\n", "--- Matrix [min, max] : [ 1.260E-01, 1.000E+00]\n", - "--- Executing CPLEX (Solvelink=2): elapsed 0:00:00.001\n", + "--- Executing CPLEX (Solvelink=2): elapsed 0:00:00.002\n", "\n", "IBM ILOG CPLEX 48.5.0 5f05ac2f Dec 20, 2024 LEG x86 64bit/Linux \n", "\n", @@ -1105,11 +1095,11 @@ "Objective: 153.675000\n", "\n", "--- Reading solution for model transport\n", - "--- Executing after solve: elapsed 0:00:00.012\n", - "--- _8a519651-4d31-457c-88b6-9bcb50a20b49.gms(260) 4 Mb\n", - "--- GDX File /tmp/tmp8ab9them/_8a519651-4d31-457c-88b6-9bcb50a20b49out.gdx\n", + "--- Executing after solve: elapsed 0:00:00.016\n", + "--- _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms(260) 4 Mb\n", + "--- GDX File /tmp/tmpdie1ewav/_c2d740e2-1c4c-4113-80e8-3c65b022cdfeout.gdx\n", "*** Status: Normal completion\n", - "--- Job _8a519651-4d31-457c-88b6-9bcb50a20b49.gms Stop 01/02/25 09:15:04 elapsed 0:00:00.012\n" + "--- Job _c2d740e2-1c4c-4113-80e8-3c65b022cdfe.gms Stop 01/02/25 09:26:25 elapsed 0:00:00.016\n" ] }, { @@ -1153,7 +1143,7 @@ " 7\n", " LP\n", " CPLEX\n", - " 0\n", + " 0.001\n", " \n", " \n", "\n", @@ -1164,7 +1154,7 @@ "0 Normal OptimalGlobal 153.675 6 7 \n", "\n", " Model Type Solver Solver Time \n", - "0 LP CPLEX 0 " + "0 LP CPLEX 0.001 " ] }, "execution_count": 20, @@ -1569,7 +1559,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.20" + "version": "3.13.0" } }, "nbformat": 4, From f9aa506aaa2f90b1f2c596524f22d26ec897ab10 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Thu, 2 Jan 2025 13:09:18 +0300 Subject: [PATCH 046/135] use real domains instead of dim + temporarily set propbounds to False in existing test --- src/gamspy/formulations/shape.py | 14 +++++++------- tests/unit/test_nn_formulation.py | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index 7c44f7f9..6fc1b468 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -6,7 +6,6 @@ import gamspy as gp import gamspy.formulations.nn.utils as utils from gamspy.exceptions import ValidationError -from gamspy.math import dim def _get_new_domain( @@ -54,16 +53,17 @@ def _generate_index_matching_statement( def _propagate_bounds(x, out): m = x.container - bounds = m.addParameter(domain=dim([2, *x.shape])) - bounds[("0",) + tuple(x.domain)] = x.lo[...] - bounds[("1",) + tuple(x.domain)] = x.up[...] + bounds_set = m.addSet(records=["lb", "ub"]) + bounds = m.addParameter(domain=[bounds_set, *x.domain]) + bounds[("lb",) + tuple(x.domain)] = x.lo[...] + bounds[("ub",) + tuple(x.domain)] = x.up[...] - nb_dom = dim([2, *out.shape]) + nb_dom = [bounds_set, *out.domain] nb_data = bounds.toDense().reshape((2,) + out.shape) new_bounds = m.addParameter(domain=nb_dom, records=nb_data) - out.lo[...] = new_bounds[("0",) + tuple(out.domain)] - out.up[...] = new_bounds[("1",) + tuple(out.domain)] + out.lo[...] = new_bounds[("lb",) + tuple(out.domain)] + out.up[...] = new_bounds[("ub",) + tuple(out.domain)] def _flatten_dims_var( diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index be3fa075..41177444 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -1820,9 +1820,9 @@ def test_flatten_var_copied_domain(data): fix_var = gp.Parameter(m, "var_ii_data", domain=var.domain, records=data) var.fx[ii, a1, a2, a3] = fix_var[ii, a1, a2, a3] - var_2, eqs = flatten_dims(var, [2, 3]) - var_3, eqs_2 = flatten_dims(var_2, [0, 1]) - var_4, eqs_3 = flatten_dims(var_3, [0, 1]) + var_2, eqs = flatten_dims(var, [2, 3], propagate_bounds=False) + var_3, eqs_2 = flatten_dims(var_2, [0, 1], propagate_bounds=False) + var_4, eqs_3 = flatten_dims(var_3, [0, 1], propagate_bounds=False) model = gp.Model( m, From f2516198dbb8d2add6d120b2e56cd02687fd2e1d Mon Sep 17 00:00:00 2001 From: emrdagkusu Date: Thu, 2 Jan 2025 11:32:57 +0100 Subject: [PATCH 047/135] add headings and descriptions to formulation doc --- docs/user/ml/formulations.rst | 87 +++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 14 deletions(-) diff --git a/docs/user/ml/formulations.rst b/docs/user/ml/formulations.rst index 6815ab07..c9f36a15 100644 --- a/docs/user/ml/formulations.rst +++ b/docs/user/ml/formulations.rst @@ -71,16 +71,44 @@ embed your convolutional layer into your optimization model. Supported formulations: -- :meth:`Linear ` -- :meth:`Conv2d ` -- :meth:`MaxPool2d ` -- :meth:`MinPool2d ` -- :meth:`AvgPool2d ` -- :meth:`flatten_dims ` +------------------------------ +:meth:`Linear ` +------------------------------ +Formulation generator for Linear layer in GAMS. It applies an affine +linear transformation to the incoming data: :math:`y = x A^T + b`. +------------------------------ +:meth:`Conv2d ` +------------------------------ +Formulation generator for 2D Convolution symbol in GAMS. It applies a +2D convolution over an input signal composed of several input planes. -.. _pooling-linearization: +------------------------------ +:meth:`MaxPool2d ` +------------------------------ +Formulation generator for 2D Max Pooling in GAMS. It pplies a 2D +max pooling over an input signal composed of several input planes. +------------------------------ +:meth:`MinPool2d ` +------------------------------ +Formulation generator for 2D Min Pooling in GAMS. It pplies a 2D +min pooling over an input signal composed of several input planes. + +------------------------------ +:meth:`AvgPool2d ` +------------------------------ +Formulation generator for 2D Avg Pooling in GAMS. It applies a 2D +average pooling over an input signal composed of several input planes. + +------------------------------ +:meth:`flatten_dims ` +------------------------------ +Flatten domains indicated by dims into a single domain. + + +.. _pooling-linearization: +------------------------------ Max/Min Pooling Implementation ------------------------------ @@ -151,12 +179,36 @@ integrating them into optimization models can be challenging. To assist you, we have started with a small list of commonly used activation functions. So far, we have implemented the following activation functions: -- :meth:`relu_with_binary_var ` -- :meth:`relu_with_complementarity_var ` -- :meth:`relu_with_sos1_var ` -- :meth:`softmax ` -- :meth:`log_softmax ` +------------------------------ +:meth:`relu_with_binary_var ` +------------------------------ +Implements the ReLU activation function using binary variables. + +------------------------------ +:meth:`relu_with_complementarity_var ` +------------------------------ +Implements the ReLU activation function using complementarity conditions. + +------------------------------ +:meth:`relu_with_sos1_var ` +------------------------------ +Implements the ReLU activation function using `SOS1 `_ variables. + +------------------------------ +:meth:`softmax ` +------------------------------ +Implements the softmax activation function. This function strictly +requires a GAMSPy Variable, y = softmax(x). +------------------------------ +:meth:`log_softmax ` +------------------------------ +Implements the log_softmax activation function. This function strictly +requires a GAMSPy Variable, y = log_softmax(x). + +------------------------------ +Activation Functions Explanation +------------------------------ Unlike other mathematical functions, these activation functions return a variable instead of an expression. This is because ReLU cannot be represented by a single expression. Directly writing ``y = max(x, 0)`` without reformulating @@ -202,8 +254,15 @@ To read more about `classification of models Additionally, we offer our established functions that can also be used as activation functions: -- :meth:`tanh ` -- :meth:`sigmoid ` +------------------------------ +:meth:`tanh ` +------------------------------ +It applies the Hyperbolic Tangent (Tanh) function element-wise. + +------------------------------ +:meth:`sigmoid ` +------------------------------ +It applies the Sigmoid function element-wise. These functions return expressions like the other math functions. So, you need to create equations and variables yourself. From ce8e29bb1254f7077c573203628bbbc8c47bcb1c Mon Sep 17 00:00:00 2001 From: emrdagkusu Date: Thu, 2 Jan 2025 15:35:24 +0100 Subject: [PATCH 048/135] update descriptions and add code blocks to formulations docs --- docs/user/ml/formulations.rst | 135 ++++++++++++++++++++++------------ 1 file changed, 87 insertions(+), 48 deletions(-) diff --git a/docs/user/ml/formulations.rst b/docs/user/ml/formulations.rst index c9f36a15..456ce194 100644 --- a/docs/user/ml/formulations.rst +++ b/docs/user/ml/formulations.rst @@ -22,9 +22,8 @@ structures into your into your optimization model. We started with formulations for computer vision-related structures such as convolution and pooling operations. -Convolution by definition requires no linearization, but it is tedious to write -down. Now you can use :meth:`Conv2d ` to easily -embed your convolutional layer into your optimization model. +Here is an example utilizing several different layers to easily +embed into your optimization model. .. code-block:: python @@ -71,44 +70,91 @@ embed your convolutional layer into your optimization model. Supported formulations: ------------------------------- :meth:`Linear ` ------------------------------- -Formulation generator for Linear layer in GAMS. It applies an affine -linear transformation to the incoming data: :math:`y = x A^T + b`. +------------------------------------------------------- +Formulation generator for Linear layer in GAMS. It applies a linear mapping +with a transformation and bias to the input data, expressed as :math:`y = x A^T + b`. + +.. code-block:: python + + import gamspy as gp + + m = gp.Container() + l1 = gp.formulations.Linear(m, 128, 64) ------------------------------- :meth:`Conv2d ` ------------------------------- +------------------------------------------------------- Formulation generator for 2D Convolution symbol in GAMS. It applies a -2D convolution over an input signal composed of several input planes. +2D convolution operation on an input signal consisting of multiple input planes. + +.. code-block:: python + + import gamspy as gp + import numpy as np + + w1 = np.random.rand(2, 1, 3, 3) + b1 = np.random.rand(2) + m = gp.Container() + + # in_channels=1, out_channels=2, kernel_size=3x3 + conv1 = gp.formulations.Conv2d(m, 1, 2, 3) + conv1.load_weights(w1, b1) ------------------------------- :meth:`MaxPool2d ` ------------------------------- -Formulation generator for 2D Max Pooling in GAMS. It pplies a 2D -max pooling over an input signal composed of several input planes. +------------------------------------------------------- +Formulation generator for 2D Max Pooling in GAMS. It applies a 2D +max pooling on an input signal consisting of multiple input planes. + +.. code-block:: python + + import gamspy as gp + + m = gp.Container() + # 2x2 max pooling + mp1 = gp.formulations.MaxPool2d(m, (2, 2)) ------------------------------- :meth:`MinPool2d ` ------------------------------- -Formulation generator for 2D Min Pooling in GAMS. It pplies a 2D -min pooling over an input signal composed of several input planes. +------------------------------------------------------- +Formulation generator for 2D Min Pooling in GAMS. It applies a 2D +min pooling on an input signal consisting of multiple input planes. + +.. code-block:: python + + import gamspy as gp + + m = gp.Container() + # 2x2 min pooling + mp1 = gp.formulations.MinPool2d(m, (2, 2)) ------------------------------- :meth:`AvgPool2d ` ------------------------------- +------------------------------------------------------- Formulation generator for 2D Avg Pooling in GAMS. It applies a 2D -average pooling over an input signal composed of several input planes. +average pooling on an input signal consisting of multiple input planes. + +.. code-block:: python + + import gamspy as gp + + m = gp.Container() + # 2x2 avg pooling + ap1 = gp.formulations.AvgPool2d(m, (2, 2)) ------------------------------- :meth:`flatten_dims ` ------------------------------- -Flatten domains indicated by dims into a single domain. +------------------------------------------------------- +It combines the domains specified by dims into a single unified domain. + +.. code-block:: python + + import gamspy as gp + from gamspy.math import dim + + m = gp.Container() + inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) + out, eqs = gp.formulations.flatten_dims(inp, [2, 3]) .. _pooling-linearization: ------------------------------- + Max/Min Pooling Implementation ------------------------------ @@ -125,11 +171,11 @@ likely continuous, but there is no restriction. :math:`p` is the variable that i the output of the pooling operation on the blue region. Depending on the operation, it is either min or max of the corresponding input points. -| +| .. image:: ../images/pooling.png - :align: center - + :align: center | + The linearization of the :math:`p = \max(a,b,c,d)` is as follows: .. math:: @@ -179,41 +225,36 @@ integrating them into optimization models can be challenging. To assist you, we have started with a small list of commonly used activation functions. So far, we have implemented the following activation functions: ------------------------------- :meth:`relu_with_binary_var ` ------------------------------- +--------------------------------------------------------------- Implements the ReLU activation function using binary variables. ------------------------------- :meth:`relu_with_complementarity_var ` ------------------------------- +--------------------------------------------------------------------------------- Implements the ReLU activation function using complementarity conditions. ------------------------------- :meth:`relu_with_sos1_var ` ------------------------------- +----------------------------------------------------------- Implements the ReLU activation function using `SOS1 `_ variables. ------------------------------- :meth:`softmax ` ------------------------------- +------------------------------------- Implements the softmax activation function. This function strictly requires a GAMSPy Variable, y = softmax(x). ------------------------------- :meth:`log_softmax ` ------------------------------- +--------------------------------------------- Implements the log_softmax activation function. This function strictly requires a GAMSPy Variable, y = log_softmax(x). ------------------------------- + Activation Functions Explanation ------------------------------- +-------------------------------- Unlike other mathematical functions, these activation functions return a -variable instead of an expression. This is because ReLU cannot be represented -by a single expression. Directly writing ``y = max(x, 0)`` without reformulating -it would result in a Discontinuous Nonlinear Program (``DNLP``) model, which is -highly undesirable. Currently, you can either use +variable and a list of equations instead of an expression. This is because ReLU +cannot be representedby a single expression. Directly writing ``y = max(x, 0)`` +without reformulating it would result in a Discontinuous Nonlinear Program (``DNLP``) model, +which is highly undesirable. Currently, you can either use :meth:`relu_with_binary_var ` to introduce binary variables into your problem, or :meth:`relu_with_complementarity_var ` @@ -254,14 +295,12 @@ To read more about `classification of models Additionally, we offer our established functions that can also be used as activation functions: ------------------------------- :meth:`tanh ` ------------------------------- +------------------------------- It applies the Hyperbolic Tangent (Tanh) function element-wise. ------------------------------- :meth:`sigmoid ` ------------------------------- +------------------------------------- It applies the Sigmoid function element-wise. These functions return expressions like the other math functions. So, you From 56cfc9d87be829b6a223aadf9a89a3eb2ef4e46f Mon Sep 17 00:00:00 2001 From: emrdagkusu Date: Thu, 2 Jan 2025 15:52:31 +0100 Subject: [PATCH 049/135] update code blocks formulation docs --- docs/user/ml/formulations.rst | 49 +++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/user/ml/formulations.rst b/docs/user/ml/formulations.rst index 456ce194..a499ac20 100644 --- a/docs/user/ml/formulations.rst +++ b/docs/user/ml/formulations.rst @@ -78,9 +78,19 @@ with a transformation and bias to the input data, expressed as :math:`y = x A^T .. code-block:: python import gamspy as gp + import numpy as np + from gamspy.math import dim m = gp.Container() l1 = gp.formulations.Linear(m, 128, 64) + w = np.random.rand(64, 128) + b = np.random.rand(64) + l1.load_weights(w, b) + x = gp.Variable(m, "x", domain=dim([10, 128])) + y, set_y = l1(x) + + [d.name for d in y.domain] + # ['DenseDim10_1', 'DenseDim64_1'] :meth:`Conv2d ` ------------------------------------------------------- @@ -91,14 +101,22 @@ Formulation generator for 2D Convolution symbol in GAMS. It applies a import gamspy as gp import numpy as np + from gamspy.math import dim w1 = np.random.rand(2, 1, 3, 3) b1 = np.random.rand(2) m = gp.Container() - # in_channels=1, out_channels=2, kernel_size=3x3 conv1 = gp.formulations.Conv2d(m, 1, 2, 3) conv1.load_weights(w1, b1) + # 10 images, 1 channel, 24 by 24 + inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) + out, eqs = conv1(inp) + + type(out) + # + [len(x) for x in out.domain] + # [10, 2, 22, 22] :meth:`MaxPool2d ` ------------------------------------------------------- @@ -108,10 +126,18 @@ max pooling on an input signal consisting of multiple input planes. .. code-block:: python import gamspy as gp + from gamspy.math import dim m = gp.Container() # 2x2 max pooling mp1 = gp.formulations.MaxPool2d(m, (2, 2)) + inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) + out, eqs = mp1(inp) + + type(out) + # + [len(x) for x in out.domain] + # [10, 1, 12, 12] :meth:`MinPool2d ` ------------------------------------------------------- @@ -121,10 +147,18 @@ min pooling on an input signal consisting of multiple input planes. .. code-block:: python import gamspy as gp + from gamspy.math import dim m = gp.Container() # 2x2 min pooling mp1 = gp.formulations.MinPool2d(m, (2, 2)) + inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) + out, eqs = mp1(inp) + + type(out) + # + [len(x) for x in out.domain] + # [10, 1, 12, 12] :meth:`AvgPool2d ` ------------------------------------------------------- @@ -134,10 +168,18 @@ average pooling on an input signal consisting of multiple input planes. .. code-block:: python import gamspy as gp + from gamspy.math import dim m = gp.Container() # 2x2 avg pooling ap1 = gp.formulations.AvgPool2d(m, (2, 2)) + inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) + out, eqs = ap1(inp) + + type(out) + # + [len(x) for x in out.domain] + # [10, 1, 12, 12] :meth:`flatten_dims ` ------------------------------------------------------- @@ -147,11 +189,14 @@ It combines the domains specified by dims into a single unified domain. import gamspy as gp from gamspy.math import dim - m = gp.Container() inp = gp.Variable(m, domain=dim((10, 1, 24, 24))) out, eqs = gp.formulations.flatten_dims(inp, [2, 3]) + type(out) + # + [len(x) for x in out.domain] + # [10, 1, 576] .. _pooling-linearization: From ac9a01939f053b499342dd372ade42419a49ad73 Mon Sep 17 00:00:00 2001 From: emrdagkusu Date: Thu, 2 Jan 2025 16:23:09 +0100 Subject: [PATCH 050/135] update changelog for formulation doc update --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f3fa44..c7272628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ GAMSPy 1.4.0 - Remove non-negative variable type from the docs. - Add plausible.js for analytics. - Minor update in embedding nn documentation. + - Add descriptions and example code to formulations documentation. GAMSPy 1.3.1 From 7e8ed6bc1474ea7686e4519a3866599e0108d688 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Thu, 2 Jan 2025 21:09:45 +0300 Subject: [PATCH 051/135] add validation to type of propagate_bound --- tests/unit/test_nn_formulation.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index 41177444..b2d15d25 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -1766,6 +1766,13 @@ def test_flatten_bad(data): pytest.raises( ValidationError, flatten_dims, par_input, [1, 3] ) # non consecutive + pytest.raises( + ValidationError, + flatten_dims, + par_input, + [0, 1], + propagate_bounds="True", + ) # propagate_bounds not bool i = gp.Set(m, "i") j = gp.Set(m, "j") From 56f8029e18014d6bbd641b5b0ed8aaf2f5b9e7f2 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Fri, 3 Jan 2025 12:46:12 +0300 Subject: [PATCH 052/135] Start migrating cli to typer --- pyproject.toml | 4 +- src/gamspy/__main__.py | 2 +- src/gamspy/_cli/cli.py | 59 +++ src/gamspy/_cli/cmdline.py | 852 ----------------------------------- src/gamspy/_cli/install.py | 13 + src/gamspy/_cli/list.py | 84 ++++ src/gamspy/_cli/probe.py | 45 ++ src/gamspy/_cli/retrieve.py | 75 +++ src/gamspy/_cli/run.py | 13 + src/gamspy/_cli/show.py | 46 ++ src/gamspy/_cli/uninstall.py | 13 + 11 files changed, 352 insertions(+), 854 deletions(-) create mode 100644 src/gamspy/_cli/cli.py delete mode 100644 src/gamspy/_cli/cmdline.py create mode 100644 src/gamspy/_cli/install.py create mode 100644 src/gamspy/_cli/list.py create mode 100644 src/gamspy/_cli/probe.py create mode 100644 src/gamspy/_cli/retrieve.py create mode 100644 src/gamspy/_cli/run.py create mode 100644 src/gamspy/_cli/show.py create mode 100644 src/gamspy/_cli/uninstall.py diff --git a/pyproject.toml b/pyproject.toml index 80f7d7e6..db56bc2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "pydantic >= 2.0", "certifi >= 2022.09.14", "urllib3 >= 2.0.7", + "typer >= 0.15.1", ] [project.urls] @@ -84,7 +85,7 @@ doc = [ ] [project.scripts] -gamspy = "gamspy._cli.cmdline:main" +gamspy = "gamspy._cli.cli:main" [tool.mypy] warn_unused_configs = true @@ -130,6 +131,7 @@ exclude = [ "node_modules", "site-packages", "venv", + "_cli", "_options.py" ] diff --git a/src/gamspy/__main__.py b/src/gamspy/__main__.py index f0cfdcd9..579ec619 100644 --- a/src/gamspy/__main__.py +++ b/src/gamspy/__main__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from gamspy._cli.cmdline import main +from gamspy._cli.cli import main if __name__ == "__main__": main() diff --git a/src/gamspy/_cli/cli.py b/src/gamspy/_cli/cli.py new file mode 100644 index 00000000..3f736b15 --- /dev/null +++ b/src/gamspy/_cli/cli.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Annotated, Union + +import typer + +from . import install, list, probe, retrieve, run, show, uninstall + +app = typer.Typer( + rich_markup_mode="rich", + context_settings={"help_option_names": ["-h", "--help"]}, +) +app.add_typer(install.app, name="install") +app.add_typer(list.app, name="list") +app.add_typer(probe.app, name="probe") +app.add_typer(retrieve.app, name="retrieve") +app.add_typer(run.app, name="run") +app.add_typer(show.app, name="show") +app.add_typer(uninstall.app, name="uninstall") + + +def version_callback(value: bool): + if value: + import gams + + import gamspy + + print(f"GAMSPy version: {gamspy.__version__}") + print(f"GAMS version: {gams.__version__}") + + try: + import gamspy_base + + print(f"gamspy_base version: {gamspy_base.__version__}") + except ModuleNotFoundError: + ... + + typer.Exit() + + +@app.callback() +def callback( + version: Annotated[ + Union[bool, None], + typer.Option( + "--version", + "-v", + help="Shows the version of gamspy, gamsapi, and gamspy_base.", + callback=version_callback, + ), + ] = None, +) -> None: ... + + +def main(): + """ + Entry point for gamspy command line application. + """ + app() diff --git a/src/gamspy/_cli/cmdline.py b/src/gamspy/_cli/cmdline.py deleted file mode 100644 index d63c5f74..00000000 --- a/src/gamspy/_cli/cmdline.py +++ /dev/null @@ -1,852 +0,0 @@ -from __future__ import annotations - -import argparse -import importlib -import os -import platform -import shutil -import subprocess -import sys -from collections.abc import Iterable - -import gamspy.utils as utils -from gamspy.exceptions import GamspyException, ValidationError - -from .util import add_solver_entry, remove_solver_entry - -USAGE = """gamspy [-h] [-v] - gamspy install license or [--uses-port ] - gamspy uninstall license - gamspy install solver [--skip-pip-install] [--existing-solvers] [--install-all-solvers] - gamspy uninstall solver [--skip-pip-uninstall] [--uninstall-all-solvers] - gamspy list solvers [--all] [--defaults] - gamspy show license - gamspy show base - gamspy probe [-j ] - gamspy retrieve license [-i ] [-o ] - gamspy run miro [--path ] [--model ] -""" - - -def get_args(): - parser = argparse.ArgumentParser( - prog="gamspy", usage=USAGE, description="GAMSPy CLI" - ) - - parser.add_argument( - "command", - choices=[ - "install", - "list", - "probe", - "retrieve", - "run", - "show", - "update", - "uninstall", - ], - type=str, - nargs="?", - help=argparse.SUPPRESS, - ) - parser.add_argument( - "component", type=str, nargs="?", default=None, help=argparse.SUPPRESS - ) - parser.add_argument("name", type=str, nargs="*", help=argparse.SUPPRESS) - parser.add_argument( - "-v", - "--version", - action="store_true", - help="Shows the version of GAMSPy, GAMS and gamspy_base", - ) - - install_license_group = parser.add_argument_group( - "gamspy install license or ", - description="Options for installing a license.", - ) - install_license_group.add_argument( - "--uses-port", - help="Interprocess communication starting port.", - ) - - _ = parser.add_argument_group( - "gamspy uninstall license", - description="Command to uninstall user license.", - ) - - install_solver_group = parser.add_argument_group( - "gamspy install solver ", - description="Options for installing solvers", - ) - install_solver_group.add_argument( - "--skip-pip-install", - "-s", - action="store_true", - help=( - "If you already have the solver installed, skip pip install and" - " update gamspy installed solver list." - ), - ) - install_solver_group.add_argument( - "--existing-solvers", - "-e", - action="store_true", - help="Reinstalls previously installed add-on solvers.", - ) - install_solver_group.add_argument( - "--install-all-solvers", - action="store_true", - help="Installs all available add-on solvers.", - ) - - uninstall_solver_group = parser.add_argument_group( - "gamspy uninstall solver ", - description="Options for uninstalling solvers", - ) - uninstall_solver_group.add_argument( - "--skip-pip-uninstall", - "-u", - action="store_true", - help=( - "If you don't want to uninstall the package of the solver, skip" - " uninstall and update gamspy installed solver list." - ), - ) - install_solver_group.add_argument( - "--uninstall-all-solvers", - action="store_true", - help="Uninstalls all installed add-on solvers.", - ) - - list_group = parser.add_argument_group( - "gamspy list solvers", description="`gamspy list solvers` options" - ) - list_group.add_argument( - "-a", "--all", action="store_true", help="Shows all available solvers." - ) - list_group.add_argument( - "-d", "--defaults", action="store_true", help="Shows default solvers." - ) - - probe_group = parser.add_argument_group( - "gamspy probe", description="`gamspy probe` options" - ) - probe_group.add_argument( - "--json-out", "-j", help="Output path for the json file." - ) - - retrieve_group = parser.add_argument_group( - "gamspy retrieve license ", - description="`gamspy retrieve license` options", - ) - retrieve_group.add_argument( - "--output", - "-o", - help="Output path for the license file.", - ) - retrieve_group.add_argument( - "--input", - "-i", - help="json file path to retrieve a license based on node information.", - ) - - miro_group = parser.add_argument_group( - "gamspy run miro", description="`gamspy run miro` options" - ) - miro_group.add_argument( - "-g", - "--model", - type=str, - help="Path to the gamspy model", - default=None, - ) - miro_group.add_argument( - "-m", - "--mode", - type=str, - choices=["config", "base", "deploy"], - help="Execution mode of MIRO", - default="base", - ) - miro_group.add_argument( - "-p", - "--path", - type=str, - help=( - "Path to the MIRO executable (.exe on Windows, .app on macOS or" - " .AppImage on Linux)" - ), - default=None, - ) - miro_group.add_argument( - "--skip-execution", - help="Whether to skip model execution", - action="store_true", - ) - - return parser.parse_args() - - -def install_license(args: argparse.Namespace): - import json - from urllib.parse import urlencode - - import urllib3 - - os.makedirs(utils.DEFAULT_DIR, exist_ok=True) - - if not args.name or len(args.name) > 1: - raise ValidationError( - "License is missing: `gamspy install license or `" - ) - - license = args.name[0] - is_alp = not os.path.isfile(license) - - if is_alp and len(license) != 36: - raise ValidationError( - f"Access code is a 36 character string or an absolute path to the " - f"license file but {len(license)} character string ({license}) provided." - ) - - gamspy_base_dir = utils._get_gamspy_base_directory() - license_path = os.path.join(utils.DEFAULT_DIR, "gamspy_license.txt") - - if is_alp: - alp_id = license - encoded_args = urlencode({"access_token": alp_id}) - request = urllib3.request( - "GET", "https://license.gams.com/license-type?" + encoded_args - ) - if request.status != 200: - raise ValidationError( - f"License server did not respond in an expected way. Request status: {request.status}. Please try again." - ) - - data = request.data.decode("utf-8", errors="replace") - cmex_type = json.loads(data)["cmex_type"] - if not cmex_type.startswith("gamspy"): - raise ValidationError( - f"Given access code `{alp_id} ({cmex_type})` is not valid for GAMSPy. " - "Make sure that you use a GAMSPy license, not a GAMS license." - ) - - command = [os.path.join(gamspy_base_dir, "gamsgetkey"), alp_id] - - if args.uses_port: - command.append("-u") - command.append(str(args.uses_port)) - - process = subprocess.run( - command, - text=True, - capture_output=True, - ) - if process.returncode: - raise ValidationError(process.stderr) - - license_text = process.stdout - lines = license_text.splitlines() - license_type = lines[0][54] - if license_type == "+": - if lines[2][:2] not in ["00", "07", "08", "09"]: - raise ValidationError( - f"Given access code `{alp_id}` is not valid for GAMSPy. " - "Make sure that you use a GAMSPy license, not a GAMS license." - ) - else: - if lines[2][8:10] not in ["00", "07", "08", "09"]: - raise ValidationError( - f"Given access code `{alp_id}` is not valid for GAMSPy. " - "Make sure that you use a GAMSPy license, not a GAMS license." - ) - - with open(license_path, "w", encoding="utf-8") as file: - file.write(license_text) - else: - with open(license) as file: - lines = file.read().splitlines() - - license_type = lines[0][54] - if license_type == "+": - if lines[2][:2] not in ["00", "07", "08", "09"]: - raise ValidationError( - f"Given license file `{license}` is not valid for GAMSPy. " - "Make sure that you use a GAMSPy license, not a GAMS license." - ) - else: - if lines[2][8:10] not in ["00", "07", "08", "09"]: - raise ValidationError( - f"Given license file `{license}` is not valid for GAMSPy. " - "Make sure that you use a GAMSPy license, not a GAMS license." - ) - - shutil.copy(license, license_path) - - -def uninstall_license(): - try: - os.unlink(os.path.join(utils.DEFAULT_DIR, "gamspy_license.txt")) - except FileNotFoundError: - ... - - -def install_solver(args: argparse.Namespace): - try: - import gamspy_base - except ModuleNotFoundError as e: - e.msg = "You must first install gamspy_base to use this functionality" - raise e - - addons_path = os.path.join(utils.DEFAULT_DIR, "solvers.txt") - os.makedirs(utils.DEFAULT_DIR, exist_ok=True) - - def install_addons(addons: Iterable[str]): - for item in addons: - solver_name = item.lower() - - if solver_name.upper() not in utils.getAvailableSolvers(): - raise ValidationError( - f'Given solver name ("{solver_name}") is not valid. Available' - f" solvers that can be installed: {utils.getAvailableSolvers()}" - ) - - if not args.skip_pip_install: - # install specified solver - try: - _ = subprocess.run( - [ - "pip", - "install", - f"gamspy-{solver_name}=={gamspy_base.__version__}", - "--force-reinstall", - ], - check=True, - stderr=subprocess.PIPE, - ) - except subprocess.CalledProcessError as e: - raise GamspyException( - f"Could not install gamspy-{solver_name}: {e.stderr.decode('utf-8')}" - ) from e - else: - try: - solver_lib = importlib.import_module( - f"gamspy_{solver_name}" - ) - except ModuleNotFoundError as e: - e.msg = f"You must install gamspy-{solver_name} first!" - raise e - - if solver_lib.__version__ != gamspy_base.__version__: - raise ValidationError( - f"gamspy_base version ({gamspy_base.__version__}) and solver" - f" version ({solver_lib.__version__}) must match! Run `gamspy" - " update` to update your solvers." - ) - - # copy solver files to gamspy_base - gamspy_base_dir = utils._get_gamspy_base_directory() - solver_lib = importlib.import_module(f"gamspy_{solver_name}") - - file_paths = solver_lib.file_paths - for file in file_paths: - shutil.copy(file, gamspy_base_dir) - - files = solver_lib.files - verbatims = [solver_lib.verbatim] - append_dist_info(files, gamspy_base_dir) - add_solver_entry(gamspy_base_dir, solver_name, verbatims) - - try: - with open(addons_path) as file: - installed = file.read().splitlines() - installed = [ - solver - for solver in installed - if solver != "" and solver != "\n" - ] - except FileNotFoundError: - installed = [] - - with open(addons_path, "w") as file: - if solver_name.upper() not in installed: - file.write( - "\n".join(installed) - + "\n" - + solver_name.upper() - + "\n" - ) - - if args.install_all_solvers: - available_solvers = utils.getAvailableSolvers() - installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) - diff = [] - for solver in available_solvers: - if solver not in installed_solvers: - diff.append(solver) - - install_addons(diff) - return - - if args.existing_solvers: - try: - with open(addons_path) as file: - solvers = file.read().splitlines() - install_addons(solvers) - return - except FileNotFoundError as e: - raise ValidationError("No existing add-on solvers found!") from e - - if not args.name: - raise ValidationError( - "Solver name is missing: `gamspy install solver `" - ) - - install_addons(args.name) - - -def append_dist_info(files, gamspy_base_dir: str): - """Updates dist-info/RECORD in site-packages for pip uninstall""" - import gamspy as gp - - gamspy_path: str = gp.__path__[0] - dist_info_path = f"{gamspy_path}-{gp.__version__}.dist-info" - - with open( - dist_info_path + os.sep + "RECORD", "a", encoding="utf-8" - ) as record: - gamspy_base_relative_path = os.sep.join( - gamspy_base_dir.split(os.sep)[-3:] - ) - - lines = [] - for file in files: - line = f"{gamspy_base_relative_path}{os.sep}{file},," - lines.append(line) - - record.write("\n".join(lines)) - - -def uninstall_solver(args: argparse.Namespace): - try: - import gamspy_base - except ModuleNotFoundError as e: - raise ValidationError( - "You must install gamspy_base to use this command!" - ) from e - - addons_path = os.path.join(utils.DEFAULT_DIR, "solvers.txt") - - def remove_addons(addons: Iterable[str]): - for item in addons: - solver_name = item.lower() - - installed_solvers = utils.getInstalledSolvers( - gamspy_base.directory - ) - if solver_name.upper() not in installed_solvers: - raise ValidationError( - f'Given solver name ("{solver_name}") is not valid. Installed' - f" solvers solvers that can be uninstalled: {installed_solvers}" - ) - - if not args.skip_pip_uninstall: - # uninstall specified solver - try: - _ = subprocess.run( - ["pip", "uninstall", f"gamspy-{solver_name}", "-y"], - check=True, - ) - except subprocess.CalledProcessError as e: - raise GamspyException( - f"Could not uninstall gamspy-{solver_name}: {e.output}" - ) from e - - # do not delete files from gamspy_base as other solvers might depend on it - gamspy_base_dir = utils._get_gamspy_base_directory() - remove_solver_entry(gamspy_base_dir, solver_name) - - try: - with open(addons_path) as file: - installed = file.read().splitlines() - except FileNotFoundError: - installed = [] - - try: - installed.remove(solver_name.upper()) - except ValueError: - ... - - with open(addons_path, "w") as file: - file.write("\n".join(installed) + "\n") - - if args.uninstall_all_solvers: - installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) - solvers = [ - solver - for solver in installed_solvers - if solver not in gamspy_base.default_solvers - ] - remove_addons(solvers) - - # All add-on solvers are gone. - return - - if not args.name: - raise ValidationError( - "Solver name is missing: `gamspy uninstall solver `" - ) - - remove_addons(args.name) - - -def install(args: argparse.Namespace): - if args.component == "license": - install_license(args) - elif args.component == "solver": - install_solver(args) - else: - raise ValidationError( - "`gamspy install` requires a third argument (license or solver)." - ) - - -def update(): - try: - import gamspy_base - except ModuleNotFoundError as e: - raise ValidationError( - "You must install gamspy_base to use this command!" - ) from e - - prev_installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) - - try: - _ = subprocess.run( - [ - "pip", - "install", - f"gamspy-base=={gamspy_base.__version__}", - "--force-reinstall", - ], - check=True, - ) - except subprocess.CalledProcessError as e: - raise GamspyException( - f"Could not uninstall gamspy-base: {e.output}" - ) from e - - new_installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) - - solvers_to_update = [] - for solver in prev_installed_solvers: - if solver not in new_installed_solvers: - solvers_to_update.append(solver) - - for solver in solvers_to_update: - try: - _ = subprocess.run( - [ - "gamspy", - "install", - "solver", - solver.lower(), - ], - check=True, - ) - except subprocess.CalledProcessError as e: - raise GamspyException( - "Could not uninstall" - f" gamspy-{solver.lower()}=={gamspy_base.version}: {e.output}" - ) from e - - -def list_solvers(args: argparse.Namespace): - try: - import gamspy_base - except ModuleNotFoundError as e: - raise ValidationError( - "You must install gamspy_base to use this command!" - ) from e - - component = args.component - - if component == "solvers": - capabilities = utils.getSolverCapabilities(gamspy_base.directory) - if args.all: - solvers = utils.getAvailableSolvers() - print("Available Solvers") - print("=" * 17) - print(", ".join(solvers)) - print( - "\nModel types that can be solved with the installed solvers:\n" - ) - for solver in solvers: - try: - print(f"{solver:<10}: {', '.join(capabilities[solver])}") - except KeyError: - ... - elif args.defaults: - default_solvers = utils.getDefaultSolvers() - print("Default Solvers") - print("=" * 17) - for problem in default_solvers: - try: - print(f"{problem:<10}: {default_solvers[problem]}") - except KeyError: - ... - else: - solvers = utils.getInstalledSolvers(gamspy_base.directory) - print("Installed Solvers") - print("=" * 17) - print(", ".join(solvers)) - - print( - "\nModel types that can be solved with the installed solvers" - ) - print("=" * 57) - for solver in solvers: - try: - print(f"{solver:<10}: {', '.join(capabilities[solver])}") - except KeyError: - ... - else: - raise ValidationError( - "gamspy list requires a third argument (solvers)." - ) - - -def run(args: argparse.Namespace): - component = args.component - - if component == "miro": - model = os.path.abspath(args.model) - mode = args.mode - path = os.getenv("MIRO_PATH", None) - - if path is None: - path = args.path if args.path is not None else discover_miro() - - if model is None or path is None: - raise GamspyException( - "--model and --path must be provided to run MIRO" - ) - - if ( - platform.system() == "Darwin" - and os.path.splitext(path)[1] == ".app" - ): - path = os.path.join(path, "Contents", "MacOS", "GAMS MIRO") - - # Initialize MIRO - if not args.skip_execution: - subprocess_env = os.environ.copy() - subprocess_env["MIRO"] = "1" - - try: - subprocess.run( - [sys.executable, model], env=subprocess_env, check=True - ) - except subprocess.CalledProcessError: - return - - # Run MIRO - subprocess_env = os.environ.copy() - if mode == "deploy": - subprocess_env["MIRO_BUILD"] = "true" - mode = "base" - - subprocess_env["MIRO_MODEL_PATH"] = model - subprocess_env["MIRO_MODE"] = mode - subprocess_env["MIRO_DEV_MODE"] = "true" - subprocess_env["MIRO_USE_TMP"] = "false" - subprocess_env["PYTHON_EXEC_PATH"] = sys.executable - - subprocess.run([path], env=subprocess_env, check=True) - - return None - - -def discover_miro(): - system = platform.system() - if system == "Linux": - return None - - home = os.path.expanduser("~") - standard_locations = { - "Darwin": [ - os.path.join( - "/", - "Applications", - "GAMS MIRO.app", - "Contents", - "MacOS", - "GAMS MIRO", - ), - os.path.join( - home, - "Applications", - "GAMS MIRO.app", - "Contents", - "MacOS", - "GAMS MIRO", - ), - ], - "Windows": [ - os.path.join( - "C:\\", "Program Files", "GAMS MIRO", "GAMS MIRO.exe" - ), - os.path.join( - home, - "AppData", - "Local", - "Programs", - "GAMS MIRO", - "GAMS MIRO.exe", - ), - ], - } - - if system in ["Darwin", "Windows"]: - for location in standard_locations[system]: - if os.path.isfile(location): - return location - - return None - - -def show(args: argparse.Namespace): - if args.component == "license": - show_license() - elif args.component == "base": - show_base() - else: - raise ValidationError( - "`gamspy show` requires a third argument (license or base)." - ) - - -def show_license(): - try: - import gamspy_base - except ModuleNotFoundError as e: - raise ValidationError( - "You must install gamspy_base to use this command!" - ) from e - - license_path = utils._get_license_path(gamspy_base.directory) - print(f"License found at: {license_path}\n") - print("License Content") - print("=" * 15) - with open(license_path, encoding="utf-8") as license_file: - print(license_file.read().strip()) - - -def show_base(): - try: - import gamspy_base - except ModuleNotFoundError as e: - raise ValidationError( - "You must install gamspy_base to use this command!" - ) from e - - print(gamspy_base.directory) - - -def uninstall(args: argparse.Namespace): - if args.component == "license": - uninstall_license() - elif args.component == "solver": - uninstall_solver(args) - else: - raise ValidationError( - "`gamspy uninstall` requires a third argument (license or solver)." - ) - - -def print_version(): - import gams - - import gamspy - - print(f"GAMSPy version: {gamspy.__version__}") - print(f"GAMS version: {gams.__version__}") - - try: - import gamspy_base - - print(f"gamspy_base version: {gamspy_base.__version__}") - except ModuleNotFoundError: - ... - - -def probe(args: argparse.Namespace): - gamspy_base_dir = utils._get_gamspy_base_directory() - process = subprocess.run( - [os.path.join(gamspy_base_dir, "gamsprobe")], - text=True, - capture_output=True, - ) - - if process.returncode: - raise ValidationError(process.stderr) - - print(process.stdout) - - if args.json_out: - with open(args.json_out, "w") as file: - file.write(process.stdout) - - -def retrieve(args: argparse.Namespace): - if args.input is None or not os.path.isfile(args.input): - raise ValidationError( - f"Given path `{args.input}` is not a json file. Please use `gamspy retrieve license -i `" - ) - - if args.name is None: - raise ValidationError(f"Given licence id `{args.name}` is not valid!") - - gamspy_base_dir = utils._get_gamspy_base_directory() - process = subprocess.run( - [ - os.path.join(gamspy_base_dir, "gamsgetkey"), - args.name[0], - "-i", - args.input, - ], - text=True, - capture_output=True, - ) - - if process.returncode: - raise ValidationError(process.stderr) - - if args.output: - with open(args.output, "w") as file: - file.write(process.stdout) - - -def main(): - """ - Entry point for gamspy command line application. - """ - args = get_args() - if args.version: - print_version() - elif args.command == "install": - install(args) - elif args.command == "probe": - probe(args) - elif args.command == "retrieve": - retrieve(args) - elif args.command == "run": - run(args) - elif args.command == "show": - show(args) - elif args.command == "update": - update() - elif args.command == "list": - list_solvers(args) - elif args.command == "uninstall": - uninstall(args) diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py new file mode 100644 index 00000000..64d3f0fb --- /dev/null +++ b/src/gamspy/_cli/install.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import typer + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To install licenses and solvers.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy install license or | gamspy install solver ", + context_settings={"help_option_names": ["-h", "--help"]}, +) + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/list.py b/src/gamspy/_cli/list.py new file mode 100644 index 00000000..8f5bed35 --- /dev/null +++ b/src/gamspy/_cli/list.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Annotated, Union + +import typer +from rich import print +from rich.console import Console +from rich.table import Table + +import gamspy.utils as utils +from gamspy.exceptions import ValidationError + +console = Console() +app = typer.Typer( + rich_markup_mode="rich", + short_help="To list solvers.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy list solvers --all | gamspy list solvers --defaults", + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +@app.command() +def solvers( + all: Annotated[ + Union[bool, None], + typer.Option("--all", "-a", help="Shows all available solvers."), + ] = None, + defaults: Annotated[ + Union[bool, None], + typer.Option("--defaults", "-d", help="Shows default solvers."), + ] = None, +) -> None: + try: + import gamspy_base + except ModuleNotFoundError as e: + raise ValidationError( + "You must install gamspy_base to use this command!" + ) from e + + capabilities = utils.getSolverCapabilities(gamspy_base.directory) + if all: + solvers = utils.getAvailableSolvers() + print("Available Solvers") + print("=" * 17) + print(", ".join(solvers)) + print("\nModel types that can be solved with the installed solvers:\n") + table = Table("Solver", "Problem Types") + for solver in solvers: + try: + table.add_row(solver, ", ".join(capabilities[solver])) + except KeyError: + ... + console.print(table) + + print( + "[bold]Full list can be found here[/bold]: https://www.gams.com/latest/docs/S_MAIN.html#SOLVERS_MODEL_TYPES" + ) + elif defaults: + default_solvers = utils.getDefaultSolvers() + table = Table("Problem", "Solver") + for problem in default_solvers: + try: + table.add_row(problem, default_solvers[problem]) + except KeyError: + ... + + console.print(table) + else: + solvers = utils.getInstalledSolvers(gamspy_base.directory) + print("Installed Solvers") + print("=" * 17) + print(", ".join(solvers)) + + print("\nModel types that can be solved with the installed solvers") + print("=" * 57) + for solver in solvers: + try: + print(f"{solver:<10}: {', '.join(capabilities[solver])}") + except KeyError: + ... + + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/probe.py b/src/gamspy/_cli/probe.py new file mode 100644 index 00000000..1ac4e031 --- /dev/null +++ b/src/gamspy/_cli/probe.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import os +import subprocess +from typing import Annotated, Union + +import typer + +import gamspy.utils as utils +from gamspy.exceptions import ValidationError + + +def probe( + json_out: Annotated[ + Union[str, None], typer.Option(help="Output path for the json file.") + ] = None, +): + gamspy_base_dir = utils._get_gamspy_base_directory() + process = subprocess.run( + [os.path.join(gamspy_base_dir, "gamsprobe")], + text=True, + capture_output=True, + ) + + if process.returncode: + raise ValidationError(process.stderr) + + print(process.stdout) + + if json_out: + with open(json_out, "w") as file: + file.write(process.stdout) + + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To probe node information.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy probe --json-out .json", + context_settings={"help_option_names": ["-h", "--help"]}, + callback=probe, + invoke_without_command=True, +) + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/retrieve.py b/src/gamspy/_cli/retrieve.py new file mode 100644 index 00000000..6b258dd9 --- /dev/null +++ b/src/gamspy/_cli/retrieve.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os +import subprocess +from typing import Annotated, Union + +import typer + +import gamspy.utils as utils +from gamspy.exceptions import ValidationError + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To retrieve a license with another node's information.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy retrieve license [--input .json] [--output .json]", + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +@app.command( + help="[bold][yellow]Examples[/yellow][/bold]: gamspy retrieve license [--input .json] [--output .json]" +) +def license( + access_code: Annotated[ + str, + typer.Argument("--access-code", help="Access code of the license."), + ], + input: Annotated[ + Union[str, None], + typer.Option( + "--input", + "-i", + help="Input json file path to retrieve the license based on the node information.", + ), + ] = None, + output: Annotated[ + Union[str, None], + typer.Option( + "--output", "-o", help="Output path for the license file." + ), + ] = None, +) -> None: + if input is None or not os.path.isfile(input): + raise ValidationError( + f"Given path `{input}` is not a json file. Please use `gamspy retrieve license -i `" + ) + + if access_code is None: + raise ValidationError( + f"Given licence id `{access_code}` is not valid!" + ) + + gamspy_base_dir = utils._get_gamspy_base_directory() + process = subprocess.run( + [ + os.path.join(gamspy_base_dir, "gamsgetkey"), + access_code, + "-i", + input, + ], + text=True, + capture_output=True, + ) + + if process.returncode: + raise ValidationError(process.stderr) + + print(process.stdout) + if output: + with open(output, "w") as file: + file.write(process.stdout) + + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/run.py b/src/gamspy/_cli/run.py new file mode 100644 index 00000000..43f072c2 --- /dev/null +++ b/src/gamspy/_cli/run.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import typer + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To run your model with GAMS MIRO.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy run miro [--path ] [--model ]", + context_settings={"help_option_names": ["-h", "--help"]}, +) + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/show.py b/src/gamspy/_cli/show.py new file mode 100644 index 00000000..60498a03 --- /dev/null +++ b/src/gamspy/_cli/show.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import typer + +import gamspy.utils as utils +from gamspy.exceptions import ValidationError + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To show your license and gamspy_base directory.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy show license | gamspy show base", + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +@app.command(short_help="Shows the license content.") +def license(): + try: + import gamspy_base + except ModuleNotFoundError as e: + raise ValidationError( + "You must install gamspy_base to use this command!" + ) from e + + license_path = utils._get_license_path(gamspy_base.directory) + print(f"License found at: {license_path}\n") + print("License Content") + print("=" * 15) + with open(license_path, encoding="utf-8") as license_file: + print(license_file.read().strip()) + + +@app.command(short_help="Shows the path of gamspy_base.") +def base(): + try: + import gamspy_base + except ModuleNotFoundError as e: + raise ValidationError( + "You must install gamspy_base to use this command!" + ) from e + + print(gamspy_base.directory) + + +if __name__ == "__main__": + app() diff --git a/src/gamspy/_cli/uninstall.py b/src/gamspy/_cli/uninstall.py new file mode 100644 index 00000000..3be7abb1 --- /dev/null +++ b/src/gamspy/_cli/uninstall.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import typer + +app = typer.Typer( + rich_markup_mode="rich", + short_help="To uninstall licenses and solvers.", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy uninstall license | gamspy uninstall solver ", + context_settings={"help_option_names": ["-h", "--help"]}, +) + +if __name__ == "__main__": + app() From cb85ae9f0f041cfe9f0bb74fbbe67f5f16809127 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Fri, 3 Jan 2025 18:03:12 +0300 Subject: [PATCH 053/135] Migrate GAMSPy CLI to Typer. --- CHANGELOG.md | 1 + docs/cli/index.rst | 2 +- docs/cli/install.rst | 2 +- src/gamspy/_cli/cli.py | 42 +++++- src/gamspy/_cli/install.py | 261 +++++++++++++++++++++++++++++++++++ src/gamspy/_cli/list.py | 13 +- src/gamspy/_cli/probe.py | 45 ------ src/gamspy/_cli/retrieve.py | 3 +- src/gamspy/_cli/run.py | 135 ++++++++++++++++++ src/gamspy/_cli/uninstall.py | 105 ++++++++++++++ 10 files changed, 553 insertions(+), 56 deletions(-) delete mode 100644 src/gamspy/_cli/probe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e7bc0a..f8816df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ GAMSPy 1.4.1 ------------ - General - Fix implicit parameter validation bug. + - Migrate GAMSPy CLI to Typer. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. - Documentation diff --git a/docs/cli/index.rst b/docs/cli/index.rst index 4df326d4..621cb8dd 100644 --- a/docs/cli/index.rst +++ b/docs/cli/index.rst @@ -37,7 +37,7 @@ Example: :: gamspy show license gamspy show base gamspy probe [-j ] - gamspy retrieve license [-i ] [-o ] + gamspy retrieve license [--input ] [--output ] gamspy run miro [--path ] [--model ] GAMSPy CLI diff --git a/docs/cli/install.rst b/docs/cli/install.rst index 070444c7..33dfa355 100644 --- a/docs/cli/install.rst +++ b/docs/cli/install.rst @@ -58,7 +58,7 @@ Usage - Description * - -\-uses-port - -u - - 33333 + - - Interprocess communication starting port. Only relevant for local licenses that restrict concurrent use of GAMSPy. diff --git a/src/gamspy/_cli/cli.py b/src/gamspy/_cli/cli.py index 3f736b15..66a6b5e7 100644 --- a/src/gamspy/_cli/cli.py +++ b/src/gamspy/_cli/cli.py @@ -1,10 +1,15 @@ from __future__ import annotations +import os +import subprocess from typing import Annotated, Union import typer -from . import install, list, probe, retrieve, run, show, uninstall +from gamspy.exceptions import ValidationError +import gamspy.utils as utils + +from . import install, list, retrieve, run, show, uninstall app = typer.Typer( rich_markup_mode="rich", @@ -12,7 +17,6 @@ ) app.add_typer(install.app, name="install") app.add_typer(list.app, name="list") -app.add_typer(probe.app, name="probe") app.add_typer(retrieve.app, name="retrieve") app.add_typer(run.app, name="run") app.add_typer(show.app, name="show") @@ -49,8 +53,40 @@ def callback( callback=version_callback, ), ] = None, -) -> None: ... +) -> None: + """ + GAMSPy CLI - The [bold]gamspy[/bold] command line app. 😎 + + Install solvers and licenses, run MIRO apps, and more. + + Read more in the docs: [link=https://gamspy.readthedocs.io/en/latest/cli/index.html]https://gamspy.readthedocs.io/en/latest/cli/index.html[/link]. + """ + ... + +@app.command(short_help="To probe node information.") +def probe( + json_out: Annotated[ + Union[str, None], + typer.Option( + "--json-out", "-j", help="Output path for the JSON file." + ) + ] = None, +): + gamspy_base_dir = utils._get_gamspy_base_directory() + process = subprocess.run( + [os.path.join(gamspy_base_dir, "gamsprobe")], + text=True, + capture_output=True, + ) + + if process.returncode: + raise ValidationError(process.stderr) + + print(process.stdout) + if json_out: + with open(json_out, "w") as file: + file.write(process.stdout) def main(): """ diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index 64d3f0fb..a6883b33 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -1,6 +1,15 @@ from __future__ import annotations +import importlib +import shutil + +from typing import Annotated, Iterable, Union import typer +from gamspy.exceptions import GamspyException, ValidationError +import gamspy.utils as utils +import os +import subprocess +from .util import add_solver_entry app = typer.Typer( rich_markup_mode="rich", @@ -9,5 +18,257 @@ context_settings={"help_option_names": ["-h", "--help"]}, ) +@app.command( + help="[bold][yellow]Examples[/yellow][/bold]: gamspy install license or ", + short_help="To install a new license" +) +def license( + license: Annotated[str, typer.Argument(help="access code or path to the license file.")], + uses_port: Annotated[Union[int, None], typer.Option("--uses-port", help="Interprocess communication starting port.")] = None +): + import json + from urllib.parse import urlencode + + import urllib3 + + os.makedirs(utils.DEFAULT_DIR, exist_ok=True) + + is_alp = not os.path.isfile(license) + + if is_alp and len(license) != 36: + raise ValidationError( + f"Access code is a 36 character string or an absolute path to the " + f"license file but {len(license)} character string ({license}) provided." + ) + + gamspy_base_dir = utils._get_gamspy_base_directory() + license_path = os.path.join(utils.DEFAULT_DIR, "gamspy_license.txt") + + if is_alp: + alp_id = license + encoded_args = urlencode({"access_token": alp_id}) + request = urllib3.request( + "GET", "https://license.gams.com/license-type?" + encoded_args + ) + if request.status != 200: + raise ValidationError( + f"License server did not respond in an expected way. Request status: {request.status}. Please try again." + ) + + data = request.data.decode("utf-8", errors="replace") + cmex_type = json.loads(data)["cmex_type"] + if not cmex_type.startswith("gamspy"): + raise ValidationError( + f"Given access code `{alp_id} ({cmex_type})` is not valid for GAMSPy. " + "Make sure that you use a GAMSPy license, not a GAMS license." + ) + + command = [os.path.join(gamspy_base_dir, "gamsgetkey"), alp_id] + + if uses_port: + command.append("-u") + command.append(str(uses_port)) + + process = subprocess.run( + command, + text=True, + capture_output=True, + ) + if process.returncode: + raise ValidationError(process.stderr) + + license_text = process.stdout + lines = license_text.splitlines() + license_type = lines[0][54] + if license_type == "+": + if lines[2][:2] not in ["00", "07", "08", "09"]: + raise ValidationError( + f"Given access code `{alp_id}` is not valid for GAMSPy. " + "Make sure that you use a GAMSPy license, not a GAMS license." + ) + else: + if lines[2][8:10] not in ["00", "07", "08", "09"]: + raise ValidationError( + f"Given access code `{alp_id}` is not valid for GAMSPy. " + "Make sure that you use a GAMSPy license, not a GAMS license." + ) + + with open(license_path, "w", encoding="utf-8") as file: + file.write(license_text) + else: + with open(license) as file: + lines = file.read().splitlines() + + license_type = lines[0][54] + if license_type == "+": + if lines[2][:2] not in ["00", "07", "08", "09"]: + raise ValidationError( + f"Given license file `{license}` is not valid for GAMSPy. " + "Make sure that you use a GAMSPy license, not a GAMS license." + ) + else: + if lines[2][8:10] not in ["00", "07", "08", "09"]: + raise ValidationError( + f"Given license file `{license}` is not valid for GAMSPy. " + "Make sure that you use a GAMSPy license, not a GAMS license." + ) + + shutil.copy(license, license_path) + +def append_dist_info(files, gamspy_base_dir: str): + """Updates dist-info/RECORD in site-packages for pip uninstall""" + import gamspy as gp + + gamspy_path: str = gp.__path__[0] + dist_info_path = f"{gamspy_path}-{gp.__version__}.dist-info" + + with open( + dist_info_path + os.sep + "RECORD", "a", encoding="utf-8" + ) as record: + gamspy_base_relative_path = os.sep.join( + gamspy_base_dir.split(os.sep)[-3:] + ) + + lines = [] + for file in files: + line = f"{gamspy_base_relative_path}{os.sep}{file},," + lines.append(line) + + record.write("\n".join(lines)) + +@app.command( + short_help="To install solvers", + help="[bold][yellow]Examples[/yellow][/bold]: gamspy install solver " +) +def solver( + solver_names: Annotated[ + Union[list[str], None], + typer.Argument(help="solver names to be installed") + ] = None, + install_all_solvers: Annotated[ + Union[bool, None], + typer.Option("--install-all-solvers", help="Installs all available add-on solvers.") + ] = None, + existing_solvers: Annotated[ + Union[bool, None], + typer.Option("--existing-solvers", help="Reinstalls previously installed add-on solvers.") + ] = None, + skip_pip_install: Annotated[ + Union[bool, None], + typer.Option("--skip-pip-install", "-s", help="If you already have the solver installed, skip pip install and update gamspy installed solver list.") + ] = None +): + try: + import gamspy_base + except ModuleNotFoundError as e: + e.msg = "You must first install gamspy_base to use this functionality" + raise e + + addons_path = os.path.join(utils.DEFAULT_DIR, "solvers.txt") + os.makedirs(utils.DEFAULT_DIR, exist_ok=True) + + def install_addons(addons: Iterable[str]): + for item in addons: + solver_name = item.lower() + + if solver_name.upper() not in utils.getAvailableSolvers(): + raise ValidationError( + f'Given solver name ("{solver_name}") is not valid. Available' + f" solvers that can be installed: {utils.getAvailableSolvers()}" + ) + + if not skip_pip_install: + # install specified solver + try: + _ = subprocess.run( + [ + "pip", + "install", + f"gamspy-{solver_name}=={gamspy_base.__version__}", + "--force-reinstall", + ], + check=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as e: + raise GamspyException( + f"Could not install gamspy-{solver_name}: {e.stderr.decode('utf-8')}" + ) from e + else: + try: + solver_lib = importlib.import_module( + f"gamspy_{solver_name}" + ) + except ModuleNotFoundError as e: + e.msg = f"You must install gamspy-{solver_name} first!" + raise e + + if solver_lib.__version__ != gamspy_base.__version__: + raise ValidationError( + f"gamspy_base version ({gamspy_base.__version__}) and solver" + f" version ({solver_lib.__version__}) must match! Run `gamspy" + " update` to update your solvers." + ) + + # copy solver files to gamspy_base + gamspy_base_dir = utils._get_gamspy_base_directory() + solver_lib = importlib.import_module(f"gamspy_{solver_name}") + + file_paths = solver_lib.file_paths + for file in file_paths: + shutil.copy(file, gamspy_base_dir) + + files = solver_lib.files + verbatims = [solver_lib.verbatim] + append_dist_info(files, gamspy_base_dir) + add_solver_entry(gamspy_base_dir, solver_name, verbatims) + + try: + with open(addons_path) as file: + installed = file.read().splitlines() + installed = [ + solver + for solver in installed + if solver != "" and solver != "\n" + ] + except FileNotFoundError: + installed = [] + + with open(addons_path, "w") as file: + if solver_name.upper() not in installed: + file.write( + "\n".join(installed) + + "\n" + + solver_name.upper() + + "\n" + ) + + if install_all_solvers: + available_solvers = utils.getAvailableSolvers() + installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) + diff = [] + for solver in available_solvers: + if solver not in installed_solvers: + diff.append(solver) + + install_addons(diff) + return + + if existing_solvers: + try: + with open(addons_path) as file: + solvers = file.read().splitlines() + install_addons(solvers) + return + except FileNotFoundError as e: + raise ValidationError("No existing add-on solvers found!") from e + + if solver_names is None: + raise ValidationError( + "Solver name is missing: `gamspy install solver `" + ) + + install_addons(solver_names) + if __name__ == "__main__": app() diff --git a/src/gamspy/_cli/list.py b/src/gamspy/_cli/list.py index 8f5bed35..a8c740de 100644 --- a/src/gamspy/_cli/list.py +++ b/src/gamspy/_cli/list.py @@ -40,10 +40,10 @@ def solvers( capabilities = utils.getSolverCapabilities(gamspy_base.directory) if all: solvers = utils.getAvailableSolvers() - print("Available Solvers") + print("[bold]Available Solvers[/bold]") print("=" * 17) print(", ".join(solvers)) - print("\nModel types that can be solved with the installed solvers:\n") + print("\n[bold]Model types that can be solved with the installed solvers[/bold]\n") table = Table("Solver", "Problem Types") for solver in solvers: try: @@ -67,18 +67,21 @@ def solvers( console.print(table) else: solvers = utils.getInstalledSolvers(gamspy_base.directory) - print("Installed Solvers") + print("[bold]Installed Solvers[/bold]") print("=" * 17) print(", ".join(solvers)) - print("\nModel types that can be solved with the installed solvers") + print("\n[bold]Model types that can be solved with the installed solvers[/bold]") print("=" * 57) + table = Table("Solver", "Problem Types") for solver in solvers: try: - print(f"{solver:<10}: {', '.join(capabilities[solver])}") + table.add_row(solver, ", ".join(capabilities[solver])) except KeyError: ... + console.print(table) + if __name__ == "__main__": app() diff --git a/src/gamspy/_cli/probe.py b/src/gamspy/_cli/probe.py deleted file mode 100644 index 1ac4e031..00000000 --- a/src/gamspy/_cli/probe.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -from typing import Annotated, Union - -import typer - -import gamspy.utils as utils -from gamspy.exceptions import ValidationError - - -def probe( - json_out: Annotated[ - Union[str, None], typer.Option(help="Output path for the json file.") - ] = None, -): - gamspy_base_dir = utils._get_gamspy_base_directory() - process = subprocess.run( - [os.path.join(gamspy_base_dir, "gamsprobe")], - text=True, - capture_output=True, - ) - - if process.returncode: - raise ValidationError(process.stderr) - - print(process.stdout) - - if json_out: - with open(json_out, "w") as file: - file.write(process.stdout) - - -app = typer.Typer( - rich_markup_mode="rich", - short_help="To probe node information.", - help="[bold][yellow]Examples[/yellow][/bold]: gamspy probe --json-out .json", - context_settings={"help_option_names": ["-h", "--help"]}, - callback=probe, - invoke_without_command=True, -) - -if __name__ == "__main__": - app() diff --git a/src/gamspy/_cli/retrieve.py b/src/gamspy/_cli/retrieve.py index 6b258dd9..10017c69 100644 --- a/src/gamspy/_cli/retrieve.py +++ b/src/gamspy/_cli/retrieve.py @@ -18,12 +18,13 @@ @app.command( + short_help="Retrives the license with the given node information.", help="[bold][yellow]Examples[/yellow][/bold]: gamspy retrieve license [--input .json] [--output .json]" ) def license( access_code: Annotated[ str, - typer.Argument("--access-code", help="Access code of the license."), + typer.Argument(help="Access code of the license."), ], input: Annotated[ Union[str, None], diff --git a/src/gamspy/_cli/run.py b/src/gamspy/_cli/run.py index 43f072c2..4df95868 100644 --- a/src/gamspy/_cli/run.py +++ b/src/gamspy/_cli/run.py @@ -1,7 +1,15 @@ from __future__ import annotations +import os +import platform +import subprocess +import sys +from typing import Annotated, Union +from enum import Enum import typer +from gamspy.exceptions import GamspyException, ValidationError + app = typer.Typer( rich_markup_mode="rich", short_help="To run your model with GAMS MIRO.", @@ -9,5 +17,132 @@ context_settings={"help_option_names": ["-h", "--help"]}, ) +class ModeEnum(Enum): + config = "config" + base = "base" + deploy = "deploy" + +@app.command( + help="[bold][yellow]Examples[/yellow][/bold]: gamspy run miro [--path ] [--model ]", + short_help="Runs a GAMSPY model with GAMS MIRO app." +) +def miro( + model: Annotated[ + Union[str, None], + typer.Option("--model", "-g", + help="Path to the GAMSPy model." + ) + ] = None, + mode: Annotated[ + ModeEnum, + typer.Option("--mode", "-m", + help="Execution mode of MIRO" + ) + ] = "base", # type: ignore + path: Annotated[ + Union[str, None], + typer.Option("--path", "-p", + help="Path to the MIRO executable (.exe on Windows, .app on macOS or .AppImage on Linux" + ) + ] = None, + skip_execution: Annotated[ + Union[bool, None], + typer.Option("--skip-execution", help="Whether to skip model execution.") + ] = None +) -> None: + if model is None: + raise ValidationError("--model must be provided to run MIRO") + + model = os.path.abspath(model) + execution_mode = mode.value + path = os.getenv("MIRO_PATH", None) + + if path is None: + path = path if path is not None else discover_miro() + + if path is None: + raise GamspyException( + "--path must be provided to run MIRO" + ) + + if ( + platform.system() == "Darwin" + and os.path.splitext(path)[1] == ".app" + ): + path = os.path.join(path, "Contents", "MacOS", "GAMS MIRO") + + # Initialize MIRO + if not skip_execution: + subprocess_env = os.environ.copy() + subprocess_env["MIRO"] = "1" + + try: + subprocess.run( + [sys.executable, model], env=subprocess_env, check=True + ) + except subprocess.CalledProcessError: + return + + # Run MIRO + subprocess_env = os.environ.copy() + if execution_mode == "deploy": + subprocess_env["MIRO_BUILD"] = "true" + execution_mode = "base" + + subprocess_env["MIRO_MODEL_PATH"] = model + subprocess_env["MIRO_MODE"] = execution_mode + subprocess_env["MIRO_DEV_MODE"] = "true" + subprocess_env["MIRO_USE_TMP"] = "false" + subprocess_env["PYTHON_EXEC_PATH"] = sys.executable + + subprocess.run([path], env=subprocess_env, check=True) + +def discover_miro(): + system = platform.system() + if system == "Linux": + return None + + home = os.path.expanduser("~") + standard_locations = { + "Darwin": [ + os.path.join( + "/", + "Applications", + "GAMS MIRO.app", + "Contents", + "MacOS", + "GAMS MIRO", + ), + os.path.join( + home, + "Applications", + "GAMS MIRO.app", + "Contents", + "MacOS", + "GAMS MIRO", + ), + ], + "Windows": [ + os.path.join( + "C:\\", "Program Files", "GAMS MIRO", "GAMS MIRO.exe" + ), + os.path.join( + home, + "AppData", + "Local", + "Programs", + "GAMS MIRO", + "GAMS MIRO.exe", + ), + ], + } + + if system in ["Darwin", "Windows"]: + for location in standard_locations[system]: + if os.path.isfile(location): + return location + + return None + if __name__ == "__main__": app() diff --git a/src/gamspy/_cli/uninstall.py b/src/gamspy/_cli/uninstall.py index 3be7abb1..b0119aca 100644 --- a/src/gamspy/_cli/uninstall.py +++ b/src/gamspy/_cli/uninstall.py @@ -1,4 +1,10 @@ from __future__ import annotations +import os +import subprocess +from typing import Annotated, Iterable, Union +from gamspy.exceptions import GamspyException, ValidationError +import gamspy.utils as utils +from .util import remove_solver_entry import typer @@ -9,5 +15,104 @@ context_settings={"help_option_names": ["-h", "--help"]}, ) +@app.command( + help="[bold][yellow]Examples[/yellow][/bold]: gamspy uninstall license", + short_help="To uninstall the current license" +) +def license(): + try: + os.unlink(os.path.join(utils.DEFAULT_DIR, "gamspy_license.txt")) + except FileNotFoundError: + ... + +@app.command( + help="[bold][yellow]Examples[/yellow][/bold]: gamspy uninstall solver ", + short_help="To uninstall solvers" +) +def solver( + solver_names: Annotated[ + Union[list[str], None], + typer.Argument(help="solver names to be uninstalled") + ] = None, + uninstall_all_solvers: Annotated[ + Union[bool, None], + typer.Option("--uninstall-all-solvers", help="Uninstalls all add-on solvers.") + ] = None, + skip_pip_uninstall: Annotated[ + Union[bool, None], + typer.Option("--skip-pip-install", "-s", help="If you already have the solver uninstalled, skip pip uninstall and update gamspy installed solver list.") + ] = None +): + try: + import gamspy_base + except ModuleNotFoundError as e: + raise ValidationError( + "You must install gamspy_base to use this command!" + ) from e + + addons_path = os.path.join(utils.DEFAULT_DIR, "solvers.txt") + + def remove_addons(addons: Iterable[str]): + for item in addons: + solver_name = item.lower() + + installed_solvers = utils.getInstalledSolvers( + gamspy_base.directory + ) + if solver_name.upper() not in installed_solvers: + raise ValidationError( + f'Given solver name ("{solver_name}") is not valid. Installed' + f" solvers solvers that can be uninstalled: {installed_solvers}" + ) + + if not skip_pip_uninstall: + # uninstall specified solver + try: + _ = subprocess.run( + ["pip", "uninstall", f"gamspy-{solver_name}", "-y"], + check=True, + ) + except subprocess.CalledProcessError as e: + raise GamspyException( + f"Could not uninstall gamspy-{solver_name}: {e.output}" + ) from e + + # do not delete files from gamspy_base as other solvers might depend on it + gamspy_base_dir = utils._get_gamspy_base_directory() + remove_solver_entry(gamspy_base_dir, solver_name) + + try: + with open(addons_path) as file: + installed = file.read().splitlines() + except FileNotFoundError: + installed = [] + + try: + installed.remove(solver_name.upper()) + except ValueError: + ... + + with open(addons_path, "w") as file: + file.write("\n".join(installed) + "\n") + + if uninstall_all_solvers: + installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) + solvers = [ + solver + for solver in installed_solvers + if solver not in gamspy_base.default_solvers + ] + remove_addons(solvers) + + # All add-on solvers are gone. + return + + if solver_names is None: + raise ValidationError( + "Solver name is missing: `gamspy uninstall solver `" + ) + + remove_addons(solver_names) + if __name__ == "__main__": app() From e7c99ea44b8b37fa3023ce780325d909ba7cbe2e Mon Sep 17 00:00:00 2001 From: msoyturk Date: Fri, 3 Jan 2025 18:10:54 +0300 Subject: [PATCH 054/135] use List from typing for typer --- src/gamspy/_cli/install.py | 4 ++-- src/gamspy/_cli/uninstall.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index a6883b33..badd8726 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -2,7 +2,7 @@ import importlib import shutil -from typing import Annotated, Iterable, Union +from typing import Annotated, Iterable, Union, List import typer from gamspy.exceptions import GamspyException, ValidationError @@ -142,7 +142,7 @@ def append_dist_info(files, gamspy_base_dir: str): ) def solver( solver_names: Annotated[ - Union[list[str], None], + Union[List[str], None], typer.Argument(help="solver names to be installed") ] = None, install_all_solvers: Annotated[ diff --git a/src/gamspy/_cli/uninstall.py b/src/gamspy/_cli/uninstall.py index b0119aca..a4e294af 100644 --- a/src/gamspy/_cli/uninstall.py +++ b/src/gamspy/_cli/uninstall.py @@ -1,7 +1,7 @@ from __future__ import annotations import os import subprocess -from typing import Annotated, Iterable, Union +from typing import Annotated, Iterable, Union, List from gamspy.exceptions import GamspyException, ValidationError import gamspy.utils as utils from .util import remove_solver_entry @@ -31,7 +31,7 @@ def license(): ) def solver( solver_names: Annotated[ - Union[list[str], None], + Union[List[str], None], typer.Argument(help="solver names to be uninstalled") ] = None, uninstall_all_solvers: Annotated[ From 6d39bb0c2d7285005e2d89ab1f13d04b05fd1380 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Fri, 3 Jan 2025 18:38:13 +0300 Subject: [PATCH 055/135] fix python 3.9 issue --- src/gamspy/_cli/install.py | 2 +- src/gamspy/_cli/uninstall.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index badd8726..81a547f8 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -143,7 +143,7 @@ def append_dist_info(files, gamspy_base_dir: str): def solver( solver_names: Annotated[ Union[List[str], None], - typer.Argument(help="solver names to be installed") + typer.Argument(default=None, help="solver names to be installed") ] = None, install_all_solvers: Annotated[ Union[bool, None], diff --git a/src/gamspy/_cli/uninstall.py b/src/gamspy/_cli/uninstall.py index a4e294af..bcae8dbf 100644 --- a/src/gamspy/_cli/uninstall.py +++ b/src/gamspy/_cli/uninstall.py @@ -32,7 +32,7 @@ def license(): def solver( solver_names: Annotated[ Union[List[str], None], - typer.Argument(help="solver names to be uninstalled") + typer.Argument(default=None, help="solver names to be uninstalled") ] = None, uninstall_all_solvers: Annotated[ Union[bool, None], From 3f3a666b667e7f9718e3d6ae7294f8c7cf1e5a41 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Fri, 3 Jan 2025 20:29:26 +0300 Subject: [PATCH 056/135] minor adjustments in Linear --- src/gamspy/formulations/nn/linear.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/gamspy/formulations/nn/linear.py b/src/gamspy/formulations/nn/linear.py index 988ce2b0..815176ff 100644 --- a/src/gamspy/formulations/nn/linear.py +++ b/src/gamspy/formulations/nn/linear.py @@ -205,7 +205,7 @@ def __call__( Otherwise, the output variable is unbounded. """ if not isinstance(propagate_bounds, bool): - raise TypeError("propagate_bounds should be a boolean.") + raise ValidationError("propagate_bounds should be a boolean.") if self.weight is None: raise ValidationError( @@ -240,6 +240,7 @@ def __call__( propagate_bounds and self._state == 1 and isinstance(input, gp.Variable) + and input.records is not None ): x_bounds_name = "x_bounds_" + str(uuid.uuid4()).split("-")[0] out_bounds_name = "out_bounds_" + str(uuid.uuid4()).split("-")[0] From a95fac951b409b0118468e5a29c41fe78cf55e58 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Fri, 3 Jan 2025 20:47:38 +0300 Subject: [PATCH 057/135] Deal with zero bounds in Linear --- src/gamspy/formulations/nn/linear.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/gamspy/formulations/nn/linear.py b/src/gamspy/formulations/nn/linear.py index 815176ff..8cc6943e 100644 --- a/src/gamspy/formulations/nn/linear.py +++ b/src/gamspy/formulations/nn/linear.py @@ -251,7 +251,10 @@ def __call__( x_bounds[("0",) + tuple(input.domain)] = input.lo[...] x_bounds[("1",) + tuple(input.domain)] = input.up[...] - x_lb, x_ub = x_bounds.toDense() + if x_bounds.records is not None: + x_lb, x_ub = x_bounds.toDense() + else: + x_lb, x_ub = np.zeros(x_bounds.shape) # To deal with infinity values in the input bounds, we convert them into complex numbers # where if the value is -inf, we convert it to 0 - 1j From 8c26ca1614a888d37421b9df33fcb4ce025704d5 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Fri, 3 Jan 2025 20:49:07 +0300 Subject: [PATCH 058/135] modify test to accept changes --- tests/unit/test_nn_formulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index b2d15d25..6550f4c6 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -2040,7 +2040,7 @@ def test_linear_propagate_bounds_non_boolean(data): lin1.load_weights(w1, b1) par_input = gp.Parameter(m, domain=dim([30, 20, 30, 20])) - pytest.raises(TypeError, lin1, par_input, "True") + pytest.raises(ValidationError, lin1, par_input, "True") def test_linear_propagate_bounded_input(data): From 5b7ea70beb3d0316e7dda5e2b7d147cefd45285b Mon Sep 17 00:00:00 2001 From: aalqershi Date: Fri, 3 Jan 2025 20:56:27 +0300 Subject: [PATCH 059/135] Deal with zero bounds (flatten_dims_par) --- src/gamspy/formulations/shape.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index 6fc1b468..2d3f8240 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -3,6 +3,8 @@ import math import uuid +import numpy as np + import gamspy as gp import gamspy.formulations.nn.utils as utils from gamspy.exceptions import ValidationError @@ -33,6 +35,9 @@ def _flatten_dims_par( ) -> tuple[gp.Parameter, list[gp.Equation]]: data = x.toDense() + if data is None: + data = np.zeros(x.shape) + m = x.container new_domain, _ = _get_new_domain(x, dims) From df0f4f8931c93888f99e67ad2ba0f6bdec9b34d8 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Fri, 3 Jan 2025 21:12:12 +0300 Subject: [PATCH 060/135] remove check that records is None because inf*0 = 0 --- src/gamspy/formulations/nn/linear.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gamspy/formulations/nn/linear.py b/src/gamspy/formulations/nn/linear.py index 8cc6943e..01bc6090 100644 --- a/src/gamspy/formulations/nn/linear.py +++ b/src/gamspy/formulations/nn/linear.py @@ -240,7 +240,6 @@ def __call__( propagate_bounds and self._state == 1 and isinstance(input, gp.Variable) - and input.records is not None ): x_bounds_name = "x_bounds_" + str(uuid.uuid4()).split("-")[0] out_bounds_name = "out_bounds_" + str(uuid.uuid4()).split("-")[0] From 891101dba81a5591d563611e3d84b92f577790df Mon Sep 17 00:00:00 2001 From: aalqershi Date: Fri, 3 Jan 2025 21:15:19 +0300 Subject: [PATCH 061/135] use utils._next_domains to deal with domains when alias is used --- src/gamspy/formulations/shape.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index 2d3f8240..e5f8f6d0 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -58,15 +58,21 @@ def _generate_index_matching_statement( def _propagate_bounds(x, out): m = x.container + + x_domain = x.domain + x_domain = utils._next_domains(x_domain, []) + bounds_set = m.addSet(records=["lb", "ub"]) - bounds = m.addParameter(domain=[bounds_set, *x.domain]) - bounds[("lb",) + tuple(x.domain)] = x.lo[...] - bounds[("ub",) + tuple(x.domain)] = x.up[...] + bounds = m.addParameter(domain=[bounds_set, *x_domain]) + + bounds[("lb",) + tuple(x_domain)] = x.lo[x_domain] + bounds[("ub",) + tuple(x_domain)] = x.up[x_domain] nb_dom = [bounds_set, *out.domain] nb_data = bounds.toDense().reshape((2,) + out.shape) new_bounds = m.addParameter(domain=nb_dom, records=nb_data) + out.lo[...] = new_bounds[("lb",) + tuple(out.domain)] out.up[...] = new_bounds[("ub",) + tuple(out.domain)] From 0c529357446be23e0f48f6a734d5890ee6c6d2c2 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Fri, 3 Jan 2025 21:16:56 +0300 Subject: [PATCH 062/135] remove temporary state of test_flatten_var_copied_domain --- tests/unit/test_nn_formulation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index 6550f4c6..080cf1f1 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -1827,9 +1827,9 @@ def test_flatten_var_copied_domain(data): fix_var = gp.Parameter(m, "var_ii_data", domain=var.domain, records=data) var.fx[ii, a1, a2, a3] = fix_var[ii, a1, a2, a3] - var_2, eqs = flatten_dims(var, [2, 3], propagate_bounds=False) - var_3, eqs_2 = flatten_dims(var_2, [0, 1], propagate_bounds=False) - var_4, eqs_3 = flatten_dims(var_3, [0, 1], propagate_bounds=False) + var_2, eqs = flatten_dims(var, [2, 3]) + var_3, eqs_2 = flatten_dims(var_2, [0, 1]) + var_4, eqs_3 = flatten_dims(var_3, [0, 1]) model = gp.Model( m, From 2938e0f176c0cd049fe5853b849a63d2b21f8582 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Fri, 3 Jan 2025 21:27:21 +0300 Subject: [PATCH 063/135] code documentation and comments --- src/gamspy/formulations/shape.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index e5f8f6d0..283bcfac 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -57,22 +57,28 @@ def _generate_index_matching_statement( def _propagate_bounds(x, out): + """Propagate bounds from the input to the output variable""" + m = x.container + # set domain for variable x_domain = x.domain x_domain = utils._next_domains(x_domain, []) bounds_set = m.addSet(records=["lb", "ub"]) bounds = m.addParameter(domain=[bounds_set, *x_domain]) + # capture original bounds bounds[("lb",) + tuple(x_domain)] = x.lo[x_domain] bounds[("ub",) + tuple(x_domain)] = x.up[x_domain] + # reshape bounds based on the output variable's shape nb_dom = [bounds_set, *out.domain] nb_data = bounds.toDense().reshape((2,) + out.shape) new_bounds = m.addParameter(domain=nb_dom, records=nb_data) + # assign new bounds to the output variable out.lo[...] = new_bounds[("lb",) + tuple(out.domain)] out.up[...] = new_bounds[("ub",) + tuple(out.domain)] @@ -121,6 +127,8 @@ def flatten_dims( ) -> tuple[gp.Parameter | gp.Variable, list[gp.Equation]]: """ Flatten domains indicated by `dims` into a single domain. + If `propagate_bounds` is True, and `x` is of type variable, + then the bounds of the input variable are propagated to the output. Parameters ---------- From 784788ea56ab0051ef5a28180fbc66a3283eab4c Mon Sep 17 00:00:00 2001 From: aalqershi Date: Fri, 3 Jan 2025 21:38:45 +0300 Subject: [PATCH 064/135] deal with zero bounds in _propagate_bounds --- src/gamspy/formulations/shape.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index 283bcfac..e8d3387f 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -73,8 +73,14 @@ def _propagate_bounds(x, out): bounds[("ub",) + tuple(x_domain)] = x.up[x_domain] # reshape bounds based on the output variable's shape + # when bounds.records is None, it means the bounds are zeros + if bounds.records is not None: + nb_data = bounds.toDense().reshape((2,) + out.shape) + else: + nb_data = np.zeros(bounds.shape).reshape((2,) + out.shape) + + # set new domain for bounds nb_dom = [bounds_set, *out.domain] - nb_data = bounds.toDense().reshape((2,) + out.shape) new_bounds = m.addParameter(domain=nb_dom, records=nb_data) From 88abe483b2026ea33c230f359a36f7f2328f8ee6 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Sat, 4 Jan 2025 20:14:55 +0300 Subject: [PATCH 065/135] add test 1 (propagate bounds of 2d var) --- tests/unit/test_nn_formulation.py | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index 080cf1f1..3cf49cf6 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -1849,6 +1849,46 @@ def test_flatten_var_copied_domain(data): assert np.allclose(out_data_2, data.reshape(400 * 400)) +def test_flatten_2d_propagate_bounds(data): + m, *_ = data + i = gp.Set(m, name="i", records=[f"i{i}" for i in range(1, 41)]) + j = gp.Set(m, name="j", records=[f"j{j}" for j in range(1, 51)]) + var = gp.Variable(m, name="var", domain=[i, j]) + + # If the variable is unbounded, the bounds are not propagated even if propagate_bounds is True + var_1, eqs_1 = flatten_dims(var, [0, 1], propagate_bounds=True) + var_2, eqs_2 = flatten_dims(var, [0, 1], propagate_bounds=False) + assert var_1.records == var_2.records + + # If the variable is bounded, the bounds are propagated + bound_up = np.random.rand(40, 50) * 5 + bound_lo = np.random.rand(40, 50) * -5 + upper = gp.Parameter(m, name="upper", domain=[i, j], records=bound_up) + lower = gp.Parameter(m, name="lower", domain=[i, j], records=bound_lo) + var.up[...] = upper[...] + var.lo[...] = lower[...] + + var_3, eqs_3 = flatten_dims(var, [0, 1]) + + model = gp.Model( + m, + "flatten_everything", + equations=[*eqs_1, *eqs_2, *eqs_3], + problem="lp", + objective=var_3["240"] + 1, + sense="min", + ) + + model.solve() + + assert np.allclose( + np.array(var_3.records.lower.tolist()), bound_lo.reshape(2000) + ) + assert np.allclose( + np.array(var_3.records.upper.tolist()), bound_up.reshape(2000) + ) + + def test_linear_bad_init(data): m, *_ = data # in feature must be integer From 7b7316908aca1ae6b386c5deff44966e5e69a959 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Sat, 4 Jan 2025 21:11:57 +0300 Subject: [PATCH 066/135] add test 2 (propagate bounds of 3d var) --- tests/unit/test_nn_formulation.py | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index 3cf49cf6..12f6b260 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -1889,6 +1889,72 @@ def test_flatten_2d_propagate_bounds(data): ) +def test_flatten_3d_propagate_bounds(): + m = gp.Container() + + i = gp.Set(m, name="i", records=[f"i{i}" for i in range(1, 41)]) + j = gp.Set(m, name="j", records=[f"j{j}" for j in range(1, 51)]) + k = gp.Set(m, name="k", records=[f"k{k}" for k in range(1, 21)]) + + var = gp.Variable(m, name="var", domain=[i, j, k]) + bounds_set = gp.Set(m, name="bounds_set", records=["lb", "ub"]) + + # If the variable is unbounded, the bounds are not propagated even if propagate_bounds is True + var_1, eqs_1 = flatten_dims(var, [0, 1], propagate_bounds=True) + var_2, eqs_2 = flatten_dims(var, [1, 2], propagate_bounds=True) + assert (var_1.records is None) and (var_2.records is None) + assert var_1.shape == (2000, 20) and var_2.shape == (40, 1000) + + # If the variable is bounded, the bounds are propagated + bound_up = np.random.rand(40, 50, 20) * 5 + bound_lo = np.random.rand(40, 50, 20) * -5 + all_bounds = np.stack([bound_lo, bound_up], axis=0) + + bounds = gp.Parameter( + m, name="bounds", domain=[bounds_set, i, j, k], records=all_bounds + ) + + var.up[...] = bounds[("ub", i, j, k)] + var.lo[...] = bounds[("lb", i, j, k)] + + var_3, eqs_3 = flatten_dims(var, [0, 1]) + var_4, eqs_4 = flatten_dims(var, [1, 2]) + var_5, eqs_5 = flatten_dims(var, [0, 1, 2]) + + model = gp.Model( + m, + "flatten_everything", + equations=[*eqs_1, *eqs_2, *eqs_3, *eqs_4, *eqs_5], + problem="lp", + objective=var_3["140", "k4"] + 1, + sense="min", + ) + + model.solve() + + var_3_bounds = gp.Parameter( + m, name="var_3_bounds", domain=[bounds_set, *var_3.domain] + ) + var_3_bounds[("lb",) + tuple(var_3.domain)] = var_3.lo[...] + var_3_bounds[("ub",) + tuple(var_3.domain)] = var_3.up[...] + + var_4_bounds = gp.Parameter( + m, name="var_4_bounds", domain=[bounds_set, *var_4.domain] + ) + var_4_bounds[("lb",) + tuple(var_4.domain)] = var_4.lo[...] + var_4_bounds[("ub",) + tuple(var_4.domain)] = var_4.up[...] + + var_5_bounds = gp.Parameter( + m, name="var_5_bounds", domain=[bounds_set, *var_5.domain] + ) + var_5_bounds[("lb",) + tuple(var_5.domain)] = var_5.lo[...] + var_5_bounds[("ub",) + tuple(var_5.domain)] = var_5.up[...] + + assert np.allclose(var_3_bounds.toDense(), all_bounds.reshape(2, 2000, 20)) + assert np.allclose(var_4_bounds.toDense(), all_bounds.reshape(2, 40, 1000)) + assert np.allclose(var_5_bounds.toDense(), all_bounds.reshape(2, 40000)) + + def test_linear_bad_init(data): m, *_ = data # in feature must be integer From e376909a17584c6d54442a348137764cb9a9eace Mon Sep 17 00:00:00 2001 From: aalqershi Date: Sat, 4 Jan 2025 21:15:53 +0300 Subject: [PATCH 067/135] fix test --- tests/unit/test_nn_formulation.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index 12f6b260..b08672b7 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -1889,13 +1889,11 @@ def test_flatten_2d_propagate_bounds(data): ) -def test_flatten_3d_propagate_bounds(): - m = gp.Container() - +def test_flatten_3d_propagate_bounds(data): + m, *_ = data i = gp.Set(m, name="i", records=[f"i{i}" for i in range(1, 41)]) j = gp.Set(m, name="j", records=[f"j{j}" for j in range(1, 51)]) k = gp.Set(m, name="k", records=[f"k{k}" for k in range(1, 21)]) - var = gp.Variable(m, name="var", domain=[i, j, k]) bounds_set = gp.Set(m, name="bounds_set", records=["lb", "ub"]) From 3f74409abda9a7eac8bd92f2185497b851116126 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Sat, 4 Jan 2025 21:23:21 +0300 Subject: [PATCH 068/135] add 2 more tests for flatten_dim --- tests/unit/test_nn_formulation.py | 107 ++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index b08672b7..4aa87ab7 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -1953,6 +1953,113 @@ def test_flatten_3d_propagate_bounds(data): assert np.allclose(var_5_bounds.toDense(), all_bounds.reshape(2, 40000)) +def test_flatten_propagate_zero_bounds(data): + m, *_ = data + var = gp.Variable(m, name="var1", domain=dim([30, 40, 10, 5])) + + var.up[...] = 0 + var.lo[...] = 0 + + var_1, eqs_1 = flatten_dims(var, [0, 1]) + var_2, eqs_2 = flatten_dims(var, [0, 1, 2, 3]) + var_3, eqs_3 = flatten_dims(var_1, [1, 2]) + + model = gp.Model( + m, + "flatten_everything", + equations=[*eqs_1, *eqs_2, *eqs_3], + problem="lp", + objective=5 * var_1["0", "0", "0"] + 1, + sense="min", + ) + + model.solve() + + expected_bounds = np.zeros([30, 40, 10, 5]) + + # Because the bounds are zero, there are no way, currently, to represent them as an array + assert np.allclose( + np.array(var_1.records.upper).reshape(var_1.shape), + expected_bounds.reshape(var_1.shape), + ) + assert np.allclose( + np.array(var_1.records.lower).reshape(var_1.shape), + expected_bounds.reshape(var_1.shape), + ) + + assert np.allclose( + np.array(var_2.records.upper).reshape(var_2.shape), + expected_bounds.reshape(var_2.shape), + ) + assert np.allclose( + np.array(var_2.records.lower).reshape(var_2.shape), + expected_bounds.reshape(var_2.shape), + ) + + assert np.allclose( + np.array(var_3.records.upper).reshape(var_3.shape), + expected_bounds.reshape(var_3.shape), + ) + assert np.allclose( + np.array(var_3.records.lower).reshape(var_3.shape), + expected_bounds.reshape(var_3.shape), + ) + + +def test_flatten_more_complex_propagate_bounds(data): + m, *_ = data + var = gp.Variable(m, name="var", domain=dim([2, 4, 5])) + bounds_set = gp.Set(m, name="bounds_set", records=["lb", "ub"]) + + bound_up = np.array( + [ + [ + [1.6873254, np.inf, 4.64399079, np.inf, 0.85146007], + [4.31392932, 1.99165668, 4.19013802, 3.77449253, 0], + [np.inf, 4.13450595, 4.25880061, 1.529363, 2.54171194], + [1.79348688, 2.04002383, 0.19198094, 4.14445882, 4.72650868], + ], + [ + [1.54070398, np.inf, 0, 3.55077501, 2.12700496], + [0.13939228, 1.10668786, 0.23710837, 3.61857607, 1.64761417], + [1.80097419, 0.89434166, 1.46039526, 1.31960681, np.inf], + [2.50636193, 1.3920737, np.inf, 3.35616509, 4.98534911], + ], + ] + ) + + bound_lo = -bound_up + all_bounds = np.stack([bound_lo, bound_up], axis=0) + + bounds = gp.Parameter( + m, name="bounds", domain=[bounds_set, *var.domain], records=all_bounds + ) + + var.up[...] = bounds[("ub",) + tuple(var.domain)] + var.lo[...] = bounds[("lb",) + tuple(var.domain)] + + var_1, eqs_1 = flatten_dims(var, [0, 1]) + + model = gp.Model( + m, + "flatten_everything", + equations=[*eqs_1], + problem="lp", + objective=5 * var_1["5", "3"] + 1, + sense="min", + ) + + model.solve() + + var_1_bounds = gp.Parameter( + m, name="var_1_bounds", domain=[bounds_set, *var_1.domain] + ) + var_1_bounds[("lb",) + tuple(var_1.domain)] = var_1.lo[...] + var_1_bounds[("ub",) + tuple(var_1.domain)] = var_1.up[...] + + assert np.allclose(var_1_bounds.toDense(), all_bounds.reshape(2, 8, 5)) + + def test_linear_bad_init(data): m, *_ = data # in feature must be integer From 61e1977c347d528d1994449b104913f7d2164779 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Sat, 4 Jan 2025 21:27:14 +0300 Subject: [PATCH 069/135] add a test for Linear that would previously fail --- tests/unit/test_nn_formulation.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index 4aa87ab7..006bfeb5 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -2357,3 +2357,26 @@ def test_linear_propagate_unbounded_input_with_zero_weight(data): # check if the bounds are zeros, since the weights are all zeros assert np.allclose(out1_ub, expected_bounds) assert np.allclose(out1_lb, expected_bounds) + + +def test_linear_propagate_zero_bounds(data): + m, *_ = data + lin1 = Linear(m, 4, 3, bias=False) + w1 = np.random.rand(3, 4) + lin1.load_weights(w1) + + x = gp.Variable(m, "x", domain=dim([2, 4])) + + x.up[...] = 0 + x.lo[...] = 0 + + out1, _ = lin1(x) + + expected_bounds = np.zeros((2, 3)) + + assert np.allclose( + np.array(out1.records.upper).reshape(out1.shape), expected_bounds + ) + assert np.allclose( + np.array(out1.records.lower).reshape(out1.shape), expected_bounds + ) From d816804a4b97496cabffbc3924e3ce570f377f1a Mon Sep 17 00:00:00 2001 From: aalqershi Date: Sat, 4 Jan 2025 21:33:08 +0300 Subject: [PATCH 070/135] fix documentation of flatten_dim --- src/gamspy/formulations/shape.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index e8d3387f..6dd6f755 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -134,7 +134,7 @@ def flatten_dims( """ Flatten domains indicated by `dims` into a single domain. If `propagate_bounds` is True, and `x` is of type variable, - then the bounds of the input variable are propagated to the output. + the bounds of the input variable are propagated to the output. Parameters ---------- From d2fc1770317557a2334b02f80d525f61f0840e46 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Sat, 4 Jan 2025 21:36:01 +0300 Subject: [PATCH 071/135] add a Changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e7bc0a..765e3f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ GAMSPy 1.4.1 ------------ - General - Fix implicit parameter validation bug. + - Allow propagating bounds to the output variable in `flatten_dims` method. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. - Documentation From 688d9a9e46e266d700cf419d750b6214a5c285e9 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 01:34:20 +0100 Subject: [PATCH 072/135] update pwl convexity docs --- src/gamspy/formulations/piecewise.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index e0daba20..45cd39da 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -406,13 +406,14 @@ def piecewise_linear_function_convexity_formulation( Here is the convexity formulation: .. math:: - x = \sum_{i}{x\_points_i * \lambda_i} + x = \\sum_{i}{x\\_points_i * \\lambda_i} - y = \sum_{i}{y\_points_i * \lambda_i} + y = \\sum_{i}{y\\_points_i * \\lambda_i} - \sum_{i}{\lambda_i} = 1 + \\sum_{i}{\\lambda_i} = 1 + + \\lambda_i \\in SOS2 - \lambda_i \in SOS2 By default, SOS2 variables are implemented using binary variables. See From 968f7bf2b52dd88708551c46959a70f2c74326cd Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 01:35:43 +0100 Subject: [PATCH 073/135] update pwl intervals docs --- src/gamspy/formulations/piecewise.py | 72 +++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 45cd39da..9a1f7ec9 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -316,13 +316,83 @@ def points_to_intervals( return result -# TODO Missing docs, tests and support for discontinuities def piecewise_linear_function_interval_formulation( input_x: gp.Variable, x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], bound_domain: bool = True, ) -> tuple[gp.Variable, list[gp.Equation]]: + """ + This function implements a piecewise linear function using the intervals formulation. + Given an input (independent) variable `input_x`, along with the defining `x_points` + and corresponding `y_points` of the piecewise function, it constructs the dependent + variable `y` and formulates the equations necessary to define the function. + + Here is the interval formulation: + + .. math:: + \\lambda_i \\geq b_i * LB_i \\quad \\forall{i} + + \\lambda_i \\leq b_i * UB_i \\quad \\forall{i} + + \\sum_{i}{b_i} = 1 + + x = \\sum_{i}{\\lambda_i} + + y = \\sum_{i}{(\\lambda_i * slope_i) + (b_i * start_i) } + + b_i \\in {0, 1} \\quad \\forall{i} + + The implementation handles discontinuities in the function. To represent a + discontinuity at a specific point `x_i`, include `x_i` twice in the `x_points` + array with corresponding values in `y_points`. For example, if `x_points` = + [1, 3, 3, 5] and `y_points` = [10, 30, 50, 70], the function allows y to take + either 30 or 50 when x = 3. Note that discontinuities introduce additional + binary variables. + + It is possible to disallow a specific range by including `None` in both + `x_points` and the corresponding `y_points`. For example, with + `x_points` = `[1, 3, None, 5, 7]` and `y_points` = `[10, 35, None, -20, 40]`, + the range between 3 and 5 is disallowed for `input_x`. + + However, `x_points` cannot start or end with a `None` value, and a `None` + value cannot be followed by another `None`. Additionally, if `x_i` is `None`, + then `y_i` must also be `None`. Similar to the discontinuities, disallowed + ranges always introduce additional binary variables. + + The input variable `input_x` is restricted to the range defined by + `x_points` unless `bound_domain` is set to False. Setting `bound_domain` to True, + creates SOS1 type of variables. When `input_x` is not bound, you can assume as + if the first and the last line segments are extended. + + Returns the dependent variable `y` and the equations required to model the + piecewise linear relationship. + + Parameters + ---------- + x : gp.Variable + Independent variable of the piecewise linear function + x_points: typing.Sequence[int | float] + Break points of the piecewise linear function in the x-axis + y_points: typing.Sequence[int| float] + Break points of the piecewise linear function in the y-axis + bound_domain: bool = True + If input_x should be limited to interval defined by min(x_points), max(x_points) + + Returns + ------- + tuple[gp.Variable, list[Equation]] + + Examples + -------- + >>> from gamspy import Container, Variable, Set + >>> from gamspy.formulations import piecewise_linear_function_interval_formulation + >>> m = Container() + >>> x = Variable(m, "x") + >>> y, eqs = piecewise_linear_function_interval_formulation(x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37]) + + """ + if not isinstance(input_x, gp.Variable): raise ValidationError("input_x is expected to be a Variable") From 7b708ae4757caa95d7526c87bb5170cf02708c8c Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 01:39:01 +0100 Subject: [PATCH 074/135] support discontinuities in pwl interval formulation --- src/gamspy/formulations/piecewise.py | 36 +++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 9a1f7ec9..0024a91e 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -301,17 +301,29 @@ def _generate_ray( def points_to_intervals( x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], -) -> dict[tuple[int | float, int | float], tuple[int | float, int | float]]: - result = {} + discontinuous_points: typing.Sequence[int], +) -> list[tuple[int | float, int | float, int | float, int | float]]: + result: list[ + tuple[int | float, int | float, int | float, int | float] + ] = [] + finished_at_disc = False for i in range(len(x_points) - 1): + finished_at_disc = False x1 = x_points[i] x2 = x_points[i + 1] y1 = y_points[i] y2 = y_points[i + 1] - slope = (y2 - y1) / (x2 - x1) - offset = y1 - (slope * x1) - result[(x1, x2)] = (slope, offset) + if i in discontinuous_points: + result.append((x1, x1, 0, y1)) + finished_at_disc = True + else: + slope = (y2 - y1) / (x2 - x1) + offset = y1 - (slope * x1) + result.append((x1, x2, slope, offset)) + + if finished_at_disc: + result.append((x2, x2, 0, y2)) return result @@ -403,21 +415,13 @@ def piecewise_linear_function_interval_formulation( x_points, y_points ) combined_indices = list({*discontinuous_indices, *none_indices}) - - if len(combined_indices) > 0: - raise ValidationError( - "This formulation does not support discontinuities" - ) - equations = [] - intervals = points_to_intervals(x_points, y_points) + intervals = points_to_intervals(x_points, y_points, combined_indices) lowerbounds_input = [(str(i), k[0]) for i, k in enumerate(intervals)] upperbounds_input = [(str(i), k[1]) for i, k in enumerate(intervals)] - slopes_input = [(str(i), intervals[k][0]) for i, k in enumerate(intervals)] - offsets_input = [ - (str(i), intervals[k][1]) for i, k in enumerate(intervals) - ] + slopes_input = [(str(i), k[2]) for i, k in enumerate(intervals)] + offsets_input = [(str(i), k[3]) for i, k in enumerate(intervals)] input_domain = input_x.domain m = input_x.container From 0d08f9769c3b2199a1640c564ad731d7f514c6ec Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 01:42:49 +0100 Subject: [PATCH 075/135] export pwl interval formulation --- src/gamspy/formulations/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gamspy/formulations/__init__.py b/src/gamspy/formulations/__init__.py index 000e0bf4..8aad97c0 100644 --- a/src/gamspy/formulations/__init__.py +++ b/src/gamspy/formulations/__init__.py @@ -8,6 +8,7 @@ ) from gamspy.formulations.piecewise import ( piecewise_linear_function_convexity_formulation, + piecewise_linear_function_interval_formulation, ) from gamspy.formulations.shape import flatten_dims @@ -20,4 +21,5 @@ "Linear", "flatten_dims", "piecewise_linear_function_convexity_formulation", + "piecewise_linear_function_interval_formulation", ] From c61edd2848845e1fb9d44b5156df12dd8c048d2f Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 02:13:22 +0100 Subject: [PATCH 076/135] extend integration tests for pwl --- tests/integration/models/piecewiseLinear.py | 125 ++++++++++++++------ 1 file changed, 88 insertions(+), 37 deletions(-) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index 39cfcc9e..30f6d5ab 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -20,8 +20,8 @@ import gamspy.formulations.piecewise as piecewise -def pwl_suite(): - print("PWL Suite") +def pwl_suite(fct, name): + print(f"PWL Suite function: {name}") m = gp.Container() x = gp.Variable(m, name="x") @@ -72,14 +72,29 @@ def pwl_suite(): ("min", exp_min, x_at_min), ("max", exp_max, x_at_max), ]: - for using in ["sos2", "binary"]: - y, eqs = ( - gp.formulations.piecewise_linear_function_convexity_formulation( + if name == "convexity": + for using in ["sos2", "binary"]: + y, eqs = fct( x, x_points, y_points, using=using, ) + model = gp.Model( + m, + equations=eqs, + objective=y, + sense=sense, + problem="mip", + ) + model.solve() + assert y.toDense() == expected_y, f"Case {case_i} failed !" + assert x.toDense() == expected_x, f"Case {case_i} failed !" + else: + y, eqs = fct( + x, + x_points, + y_points, ) model = gp.Model( m, equations=eqs, objective=y, sense=sense, problem="mip" @@ -92,14 +107,63 @@ def pwl_suite(): # test bound cases # y is not bounded - for using in ["sos2", "binary"]: - x_points = [-4, -2, 1, 3] - y_points = [-2, 0, 0, 2] - y, eqs = ( - gp.formulations.piecewise_linear_function_convexity_formulation( + if name == "convexity": + for using in ["sos2", "binary"]: + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 2] + y, eqs = fct( x, x_points, y_points, using=using, bound_domain=False ) - ) + x.fx[...] = -5 + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), -3), "Case 5 failed !" + print("Case 5 passed !") + x.fx[...] = 100 + model.solve() + assert math.isclose(y.toDense(), 99), "Case 6 failed !" + print("Case 6 passed !") + + # y is upper bounded + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 0] + y, eqs = fct( + x, x_points, y_points, using=using, bound_domain=False + ) + model = gp.Model( + m, equations=eqs, objective=y, sense="max", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), 0), "Case 7 failed !" + print("Case 7 passed !") + x.fx[...] = 100 + model.solve() + assert math.isclose(y.toDense(), 0), "Case 8 failed !" + print("Case 8 passed !") + # y is lower bounded + x_points = [-4, -2, 1, 3] + y_points = [-5, -5, 0, 2] + y, eqs = fct( + x, x_points, y_points, using=using, bound_domain=False + ) + x.lo[...] = "-inf" + x.up[...] = "inf" + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert math.isclose(y.toDense(), -5), "Case 9 failed !" + print("Case 9 passed !") + x.fx[...] = -100 + model.solve() + assert math.isclose(y.toDense(), -5), "Case 10 failed !" + print("Case 10 passed !") + else: + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 2] + y, eqs = fct(x, x_points, y_points, bound_domain=False) x.fx[...] = -5 model = gp.Model( m, equations=eqs, objective=y, sense="min", problem="mip" @@ -115,11 +179,7 @@ def pwl_suite(): # y is upper bounded x_points = [-4, -2, 1, 3] y_points = [-2, 0, 0, 0] - y, eqs = ( - gp.formulations.piecewise_linear_function_convexity_formulation( - x, x_points, y_points, using=using, bound_domain=False - ) - ) + y, eqs = fct(x, x_points, y_points, bound_domain=False) model = gp.Model( m, equations=eqs, objective=y, sense="max", problem="mip" ) @@ -133,11 +193,7 @@ def pwl_suite(): # y is lower bounded x_points = [-4, -2, 1, 3] y_points = [-5, -5, 0, 2] - y, eqs = ( - gp.formulations.piecewise_linear_function_convexity_formulation( - x, x_points, y_points, using=using, bound_domain=False - ) - ) + y, eqs = fct(x, x_points, y_points, bound_domain=False) x.lo[...] = "-inf" x.up[...] = "inf" model = gp.Model( @@ -154,9 +210,7 @@ def pwl_suite(): # test discontinuous function not allowing in between value x_points = [1, 4, 4, 10] y_points = [1, 4, 8, 25] - y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( - x, x_points, y_points - ) + y, eqs = fct(x, x_points, y_points) x.fx[...] = 4 y.fx[...] = 6 # y can be either 4 or 8 but not their convex combination model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") @@ -169,9 +223,7 @@ def pwl_suite(): # test None case x_points = [1, 4, None, 6, 10] y_points = [1, 4, None, 8, 25] - y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( - x, x_points, y_points - ) + y, eqs = fct(x, x_points, y_points) x.fx[...] = 5 # should be IntegerInfeasible since 5 \in [4, 6] model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") res = model.solve() @@ -183,9 +235,7 @@ def pwl_suite(): # test None case x_points = [1, 4, None, 6, 10] y_points = [1, 4, None, 30, 25] - y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( - x, x_points, y_points - ) + y, eqs = fct(x, x_points, y_points) x.lo[...] = "-inf" x.up[...] = "inf" model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") @@ -197,9 +247,7 @@ def pwl_suite(): # test None case x_points = [1, 4, None, 6, 10] y_points = [1, 45, None, 30, 25] - y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( - x, x_points, y_points - ) + y, eqs = fct(x, x_points, y_points) x.lo[...] = "-inf" x.up[...] = "inf" model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") @@ -213,9 +261,7 @@ def pwl_suite(): x2 = gp.Variable(m, name="x2", domain=[i]) x_points = [1, 4, None, 6, 10, 10, 20] y_points = [1, 45, None, 30, 25, 30, 12] - y, eqs = gp.formulations.piecewise_linear_function_convexity_formulation( - x2, x_points, y_points - ) + y, eqs = fct(x2, x_points, y_points) x2.fx["1"] = 1 x2.fx["2"] = 2.5 x2.fx["3"] = 8 @@ -318,5 +364,10 @@ def indicator_suite(): if __name__ == "__main__": print("Piecewise linear function test model") - pwl_suite() + pwl_suite( + piecewise.piecewise_linear_function_convexity_formulation, "convexity" + ) + pwl_suite( + piecewise.piecewise_linear_function_interval_formulation, "interval" + ) indicator_suite() From 466859972003aa31729160f9638d04f280faefcb Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 09:48:53 +0100 Subject: [PATCH 077/135] handle unbounded case in pwl interval formulation --- src/gamspy/formulations/piecewise.py | 52 ++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 0024a91e..20a2137d 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -445,19 +445,53 @@ def piecewise_linear_function_interval_formulation( set_lambda_upperbound[...] = upperbounds * bin_var >= lambda_var equations.append(set_lambda_upperbound) + out_y = m.addVariable(domain=input_domain) + + x_term = 0 + y_term = 0 + pick_one_term = 0 + if bound_domain: + out_y.lo[...] = min(y_points) + out_y.up[...] = max(y_points) + else: + x_neg_inf, b_neg_inf, eqs_neg_inf = _generate_ray(m, input_domain) + equations.extend(eqs_neg_inf) + + x_pos_inf, b_pos_inf, eqs_pos_inf = _generate_ray(m, input_domain) + equations.extend(eqs_pos_inf) + + pick_one_term = b_neg_inf + b_pos_inf + + m_pos = (y_points[-1] - y_points[-2]) / (x_points[-1] - x_points[-2]) + m_neg = (y_points[0] - y_points[1]) / (x_points[0] - x_points[1]) + + x_term = ( + x_pos_inf + - x_neg_inf + + (b_neg_inf * x_points[0]) + + (b_pos_inf * x_points[-1]) + ) + y_term = ( + (m_pos * x_pos_inf) + - (m_neg * x_neg_inf) + + (b_neg_inf * y_points[0]) + + (b_pos_inf * y_points[-1]) + ) + pick_one = m.addEquation(domain=input_domain) - pick_one[...] = gp.Sum(J, bin_var) == 1 + pick_one[...] = gp.Sum(J, bin_var) + pick_one_term == 1 equations.append(pick_one) set_x = m.addEquation(domain=input_domain) - set_x[...] = input_x == gp.Sum(J, lambda_var) + set_x[...] = input_x == gp.Sum(J, lambda_var) + x_term equations.append(set_x) - out_y = m.addVariable(domain=input_domain) - set_y = m.addEquation(domain=input_domain) - set_y[...] = out_y == gp.Sum(J, lambda_var * slopes) + gp.Sum( - J, bin_var * offsets + set_y[...] = ( + out_y + == gp.Sum(J, lambda_var * slopes) + + gp.Sum(J, bin_var * offsets) + + y_term ) equations.append(set_y) @@ -588,10 +622,8 @@ def piecewise_linear_function_convexity_formulation( lambda_var.lo[...] = 0 lambda_var.up[...] = 1 if bound_domain: - min_y = min(y_points) - max_y = max(y_points) - out_y.lo[...] = min_y - out_y.up[...] = max_y + out_y.lo[...] = min(y_points) + out_y.up[...] = max(y_points) else: x_neg_inf, b_neg_inf, eqs_neg_inf = _generate_ray(m, input_domain) equations.extend(eqs_neg_inf) From 7fb3f12d3c0b6dc640f2fefbbd891f86a73637a4 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 10:05:09 +0100 Subject: [PATCH 078/135] fix edge case with unbounded pwl with discontinuous extremes --- src/gamspy/formulations/piecewise.py | 23 ++++++++++--- tests/integration/models/piecewiseLinear.py | 36 +++++++++++++++++++-- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 20a2137d..4e92eed5 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -328,6 +328,23 @@ def points_to_intervals( return result +def _get_end_slopes( + x_points: typing.Sequence[int | float], + y_points: typing.Sequence[int | float], +) -> tuple[float, float]: + if x_points[-1] != x_points[-2]: + m_pos = (y_points[-1] - y_points[-2]) / (x_points[-1] - x_points[-2]) + else: + m_pos = 0 + + if x_points[0] != x_points[1]: + m_neg = (y_points[0] - y_points[1]) / (x_points[0] - x_points[1]) + else: + m_neg = 0 + + return m_neg, m_pos + + def piecewise_linear_function_interval_formulation( input_x: gp.Variable, x_points: typing.Sequence[int | float], @@ -462,8 +479,7 @@ def piecewise_linear_function_interval_formulation( pick_one_term = b_neg_inf + b_pos_inf - m_pos = (y_points[-1] - y_points[-2]) / (x_points[-1] - x_points[-2]) - m_neg = (y_points[0] - y_points[1]) / (x_points[0] - x_points[1]) + m_neg, m_pos = _get_end_slopes(x_points, y_points) x_term = ( x_pos_inf @@ -644,8 +660,7 @@ def piecewise_linear_function_convexity_formulation( limit_b_pos_inf[...] = b_pos_inf <= lambda_var[[*input_domain, last]] equations.append(limit_b_pos_inf) - m_pos = (y_points[-1] - y_points[-2]) / (x_points[-1] - x_points[-2]) - m_neg = (y_points[0] - y_points[1]) / (x_points[0] - x_points[1]) + m_neg, m_pos = _get_end_slopes(x_points, y_points) x_term = x_pos_inf - x_neg_inf y_term = (m_pos * x_pos_inf) - (m_neg * x_neg_inf) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index 30f6d5ab..99df8f85 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -256,7 +256,7 @@ def pwl_suite(fct, name): assert y.toDense() == 45, "Case 14 failed !" print("Case 14 passed !") - # test piecewise_linear_function with a non-scalar input + # test with a non-scalar input i = gp.Set(m, name="i", records=["1", "2", "3", "4", "5"]) x2 = gp.Variable(m, name="x2", domain=[i]) x_points = [1, 4, None, 6, 10, 10, 20] @@ -275,7 +275,39 @@ def pwl_suite(fct, name): problem="mip", ) model.solve() - assert np.allclose(y.toDense(), np.array([1, 23, 27.5, 45, 21])) + assert np.allclose( + y.toDense(), np.array([1, 23, 27.5, 45, 21]) + ), "Case 14 failed !" + print("Case 14 passed !") + + # test unbounded when edges are discontinuous + if name == "convexity": + for using in ["binary", "sos2"]: + x_points = [-4, -4, -2, 1, 3, 3] + y_points = [20, -2, 0, 0, 2, 9] + y, eqs = fct( + x, x_points, y_points, using=using, bound_domain=False + ) + x.fx[...] = -5 + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert y.toDense() == 20, "Case 15 failed !" + + print("Case 15 passed !") + else: + x_points = [-4, -4, -2, 1, 3, 3] + y_points = [20, -2, 0, 0, 2, 9] + y, eqs = fct(x, x_points, y_points, bound_domain=False) + x.fx[...] = -5 + model = gp.Model( + m, equations=eqs, objective=y, sense="min", problem="mip" + ) + model.solve() + assert y.toDense() == 20, "Case 15 failed !" + print("Case 15 passed !") + m.close() From 46d9fcfe3019216073b6bffcbf38e4da0a3eb47b Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 10:20:22 +0100 Subject: [PATCH 079/135] shorten the names of piecewise linear functions --- src/gamspy/formulations/__init__.py | 10 ++-- src/gamspy/formulations/piecewise.py | 23 ++++--- tests/integration/models/piecewiseLinear.py | 8 +-- tests/unit/test_formulation.py | 66 +++++++++------------ 4 files changed, 45 insertions(+), 62 deletions(-) diff --git a/src/gamspy/formulations/__init__.py b/src/gamspy/formulations/__init__.py index 8aad97c0..a94d0bd9 100644 --- a/src/gamspy/formulations/__init__.py +++ b/src/gamspy/formulations/__init__.py @@ -1,4 +1,5 @@ import gamspy.formulations.nn as nn +import gamspy.formulations.piecewise as piecewise from gamspy.formulations.nn import ( AvgPool2d, Conv2d, @@ -7,19 +8,20 @@ MinPool2d, ) from gamspy.formulations.piecewise import ( - piecewise_linear_function_convexity_formulation, - piecewise_linear_function_interval_formulation, + pwl_convexity_formulation, + pwl_interval_formulation, ) from gamspy.formulations.shape import flatten_dims __all__ = [ "nn", + "piecewise", "Conv2d", "MaxPool2d", "MinPool2d", "AvgPool2d", "Linear", "flatten_dims", - "piecewise_linear_function_convexity_formulation", - "piecewise_linear_function_interval_formulation", + "pwl_convexity_formulation", + "pwl_interval_formulation", ] diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 4e92eed5..fc4f0d6e 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -345,7 +345,7 @@ def _get_end_slopes( return m_neg, m_pos -def piecewise_linear_function_interval_formulation( +def pwl_interval_formulation( input_x: gp.Variable, x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], @@ -368,7 +368,7 @@ def piecewise_linear_function_interval_formulation( x = \\sum_{i}{\\lambda_i} - y = \\sum_{i}{(\\lambda_i * slope_i) + (b_i * start_i) } + y = \\sum_{i}{(\\lambda_i * slope_i) + (b_i * offset_i) } b_i \\in {0, 1} \\quad \\forall{i} @@ -403,7 +403,7 @@ def piecewise_linear_function_interval_formulation( Independent variable of the piecewise linear function x_points: typing.Sequence[int | float] Break points of the piecewise linear function in the x-axis - y_points: typing.Sequence[int| float] + y_points: typing.Sequence[int | float] Break points of the piecewise linear function in the y-axis bound_domain: bool = True If input_x should be limited to interval defined by min(x_points), max(x_points) @@ -415,10 +415,10 @@ def piecewise_linear_function_interval_formulation( Examples -------- >>> from gamspy import Container, Variable, Set - >>> from gamspy.formulations import piecewise_linear_function_interval_formulation + >>> from gamspy.formulations import pwl_interval_formulation >>> m = Container() >>> x = Variable(m, "x") - >>> y, eqs = piecewise_linear_function_interval_formulation(x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37]) + >>> y, eqs = pwl_interval_formulation(x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37]) """ @@ -514,7 +514,7 @@ def piecewise_linear_function_interval_formulation( return out_y, equations -def piecewise_linear_function_convexity_formulation( +def pwl_convexity_formulation( input_x: gp.Variable, x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], @@ -543,11 +543,8 @@ def piecewise_linear_function_convexity_formulation( See `Modeling disjunctive constraints with a logarithmic number of binary variables and constraints `_ - . - - Internally, the function employs binary variables as the default implementation. - However, you can switch to SOS2 (Special Ordered Set Type 2) by setting the `using` - parameter to `"sos2"`. + . However, you can switch to SOS2 (Special Ordered Set Type 2) by setting the + `using` parameter to `"sos2"`. The implementation handles discontinuities in the function. To represent a discontinuity at a specific point `x_i`, include `x_i` twice in the `x_points` @@ -595,10 +592,10 @@ def piecewise_linear_function_convexity_formulation( Examples -------- >>> from gamspy import Container, Variable, Set - >>> from gamspy.formulations import piecewise_linear_function_convexity_formulation + >>> from gamspy.formulations import pwl_convexity_formulation >>> m = Container() >>> x = Variable(m, "x") - >>> y, eqs = piecewise_linear_function_convexity_formulation(x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37]) + >>> y, eqs = pwl_convexity_formulation(x, [-1, 4, 10, 10, 20], [-2, 8, 15, 17, 37]) """ if using not in {"binary", "sos2"}: diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index 99df8f85..424c43dd 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -396,10 +396,6 @@ def indicator_suite(): if __name__ == "__main__": print("Piecewise linear function test model") - pwl_suite( - piecewise.piecewise_linear_function_convexity_formulation, "convexity" - ) - pwl_suite( - piecewise.piecewise_linear_function_interval_formulation, "interval" - ) + pwl_suite(piecewise.pwl_convexity_formulation, "convexity") + pwl_suite(piecewise.pwl_interval_formulation, "interval") indicator_suite() diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index abefb7be..27d666e0 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -6,7 +6,7 @@ import gamspy.formulations.piecewise as piecewise from gamspy.exceptions import ValidationError from gamspy.formulations import ( - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, ) pytestmark = pytest.mark.unit @@ -171,10 +171,8 @@ def test_pwl_with_sos2(data): x = data["x"] x_points = data["x_points"] y_points = data["y_points"] - y, eqs = piecewise_linear_function_convexity_formulation( - x, x_points, y_points, using="sos2" - ) - y2, eqs2 = piecewise_linear_function_convexity_formulation( + y, eqs = pwl_convexity_formulation(x, x_points, y_points, using="sos2") + y2, eqs2 = pwl_convexity_formulation( x, x_points, y_points, @@ -194,12 +192,8 @@ def test_pwl_with_binary(data): x = data["x"] x_points = data["x_points"] y_points = data["y_points"] - y, eqs = piecewise_linear_function_convexity_formulation( - x, x_points, y_points, using="binary" - ) - y2, eqs2 = piecewise_linear_function_convexity_formulation( - x, x_points, y_points, using="binary" - ) + y, eqs = pwl_convexity_formulation(x, x_points, y_points, using="binary") + y2, eqs2 = pwl_convexity_formulation(x, x_points, y_points, using="binary") # there should be no sos2 variables var_count = get_var_count_by_type(m) @@ -214,12 +208,8 @@ def test_pwl_with_domain(data): x2 = data["x2"] x_points = data["x_points"] y_points = data["y_points"] - y, eqs = piecewise_linear_function_convexity_formulation( - x2, x_points, y_points, using="binary" - ) - y2, eqs2 = piecewise_linear_function_convexity_formulation( - x2, x_points, y_points, using="sos2" - ) + y, eqs = pwl_convexity_formulation(x2, x_points, y_points, using="binary") + y2, eqs2 = pwl_convexity_formulation(x2, x_points, y_points, using="sos2") assert len(y.domain) == len(x2.domain) assert len(y2.domain) == len(x2.domain) @@ -230,9 +220,7 @@ def test_pwl_with_none(data): x = data["x"] x_points = [1, None, 2, 3] y_points = [10, None, 20, 45] - y, eqs = piecewise_linear_function_convexity_formulation( - x, x_points, y_points - ) + y, eqs = pwl_convexity_formulation(x, x_points, y_points) def test_pwl_validation(data): @@ -243,7 +231,7 @@ def test_pwl_validation(data): # incorrect using value pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, x_points, y_points, @@ -253,7 +241,7 @@ def test_pwl_validation(data): # x not a variable pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, 10, x_points, y_points, @@ -262,7 +250,7 @@ def test_pwl_validation(data): # incorrect x_points, y_points pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, 10, y_points, @@ -270,7 +258,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, x_points, 10, @@ -278,7 +266,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [1], [10], @@ -286,7 +274,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, x_points, [10], @@ -294,7 +282,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [*x_points, "a"], [*y_points, 5], @@ -302,7 +290,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [*x_points, 16], [*y_points, "a"], @@ -310,7 +298,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [3, 2, 1], [10, 20, 30], @@ -318,7 +306,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [3, 1, 2], [10, 20, 30], @@ -326,7 +314,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [1, 3, 2], [10, 20, 30], @@ -334,7 +322,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [1], [10], @@ -342,7 +330,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [None, 2, 3], [None, 20, 40], @@ -350,7 +338,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [2, 3, None], [20, 40, None], @@ -358,7 +346,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [None, 2, 3, None], [None, 20, 40, None], @@ -366,7 +354,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [0, None, 2, 3], [0, 10, 20, 40], @@ -374,7 +362,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [0, 1, 2, 3], [0, None, 20, 40], @@ -382,7 +370,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [1, None, None, 2, 3], [10, None, None, 20, 40], @@ -390,7 +378,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - piecewise_linear_function_convexity_formulation, + pwl_convexity_formulation, x, [2, None, 2, 3], [10, None, 20, 40], From 8438bb55522f7a776609db711270f478da981734 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 10:37:15 +0100 Subject: [PATCH 080/135] add tests --- tests/unit/test_formulation.py | 65 +++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 27d666e0..2dab27ef 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -7,10 +7,16 @@ from gamspy.exceptions import ValidationError from gamspy.formulations import ( pwl_convexity_formulation, + pwl_interval_formulation, ) pytestmark = pytest.mark.unit +fcts_to_test = [ + pwl_convexity_formulation, + pwl_interval_formulation, +] + @pytest.fixture def data(): @@ -77,6 +83,7 @@ def test_pwl_indicator(): x = gp.Variable(m, name="x", domain=[i]) x2 = gp.Variable(m, name="x2", domain=[j]) x3 = gp.Variable(m, name="x3", domain=[k]) + x4 = gp.Variable(m, name="x4", domain=[i, k]) pytest.raises( ValidationError, piecewise._indicator, "indicator_var", 0, x <= 10 @@ -88,6 +95,7 @@ def test_pwl_indicator(): pytest.raises(ValidationError, piecewise._indicator, b, 0, x + 10) pytest.raises(ValidationError, piecewise._indicator, b, 0, x3 >= 10) pytest.raises(ValidationError, piecewise._indicator, b, 0, x2 >= 10) + pytest.raises(ValidationError, piecewise._indicator, b, 0, x4 >= 10) eqs1 = piecewise._indicator(b, 0, x >= 10) eqs2 = piecewise._indicator(b, 0, x <= 10) @@ -210,10 +218,11 @@ def test_pwl_with_domain(data): y_points = data["y_points"] y, eqs = pwl_convexity_formulation(x2, x_points, y_points, using="binary") y2, eqs2 = pwl_convexity_formulation(x2, x_points, y_points, using="sos2") + y3, eqs3 = pwl_interval_formulation(x2, x_points, y_points) assert len(y.domain) == len(x2.domain) assert len(y2.domain) == len(x2.domain) - print(eqs[2].domain) + assert len(y3.domain) == len(x2.domain) def test_pwl_with_none(data): @@ -221,9 +230,23 @@ def test_pwl_with_none(data): x_points = [1, None, 2, 3] y_points = [10, None, 20, 45] y, eqs = pwl_convexity_formulation(x, x_points, y_points) + y2, eqs2 = pwl_interval_formulation(x, x_points, y_points) + + +def test_pwl_finished_start_with_disc(data): + x = data["x"] + x_points = [1, 1, None, 2, 3, 3] + y_points = [0, 10, None, 20, 45, 0] + y, eqs = pwl_convexity_formulation( + x, x_points, y_points, bound_domain=False + ) + y2, eqs2 = pwl_interval_formulation( + x, x_points, y_points, bound_domain=False + ) -def test_pwl_validation(data): +@pytest.mark.parametrize("fct", fcts_to_test) +def test_pwl_validation(data, fct): x = data["x"] x_points = data["x_points"] y_points = data["y_points"] @@ -231,7 +254,7 @@ def test_pwl_validation(data): # incorrect using value pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, x_points, y_points, @@ -241,7 +264,7 @@ def test_pwl_validation(data): # x not a variable pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, 10, x_points, y_points, @@ -250,7 +273,7 @@ def test_pwl_validation(data): # incorrect x_points, y_points pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, 10, y_points, @@ -258,7 +281,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, x_points, 10, @@ -266,7 +289,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [1], [10], @@ -274,7 +297,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, x_points, [10], @@ -282,7 +305,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [*x_points, "a"], [*y_points, 5], @@ -290,7 +313,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [*x_points, 16], [*y_points, "a"], @@ -298,7 +321,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [3, 2, 1], [10, 20, 30], @@ -306,7 +329,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [3, 1, 2], [10, 20, 30], @@ -314,7 +337,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [1, 3, 2], [10, 20, 30], @@ -322,7 +345,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [1], [10], @@ -330,7 +353,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [None, 2, 3], [None, 20, 40], @@ -338,7 +361,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [2, 3, None], [20, 40, None], @@ -346,7 +369,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [None, 2, 3, None], [None, 20, 40, None], @@ -354,7 +377,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [0, None, 2, 3], [0, 10, 20, 40], @@ -362,7 +385,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [0, 1, 2, 3], [0, None, 20, 40], @@ -370,7 +393,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [1, None, None, 2, 3], [10, None, None, 20, 40], @@ -378,7 +401,7 @@ def test_pwl_validation(data): pytest.raises( ValidationError, - pwl_convexity_formulation, + fct, x, [2, None, 2, 3], [10, None, 20, 40], From e258a69514f11d4d55581e2457fe4c6a501037ec Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 10:49:40 +0100 Subject: [PATCH 081/135] add tests --- tests/unit/test_formulation.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 2dab27ef..6855aaed 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -406,3 +406,12 @@ def test_pwl_validation(data, fct): [2, None, 2, 3], [10, None, 20, 40], ) + + pytest.raises( + ValidationError, + fct, + x, + [2, None, 4, 10], + [10, None, 20, 40], + bound_domain="yes", + ) From 32600c320e8e3b46c686827f6c2290ffbc143e98 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Sun, 5 Jan 2025 12:58:20 +0300 Subject: [PATCH 082/135] make container optional with a context manager --- src/gamspy/__init__.py | 2 + src/gamspy/_container.py | 6 +++ src/gamspy/_model.py | 11 +++-- src/gamspy/_symbols/alias.py | 30 +++++++++---- src/gamspy/_symbols/equation.py | 26 ++++++++--- src/gamspy/_symbols/parameter.py | 26 ++++++++--- src/gamspy/_symbols/set.py | 27 +++++++++--- src/gamspy/_symbols/variable.py | 26 ++++++++--- tests/integration/test_solve.py | 76 ++++++++++++++++++++++++++++++++ 9 files changed, 193 insertions(+), 37 deletions(-) diff --git a/src/gamspy/__init__.py b/src/gamspy/__init__.py index 42b5ecfb..38fa2406 100644 --- a/src/gamspy/__init__.py +++ b/src/gamspy/__init__.py @@ -36,6 +36,8 @@ from .version import __version__ +_ctx_manager: Container | None = None + __all__ = [ "Container", "Set", diff --git a/src/gamspy/_container.py b/src/gamspy/_container.py index d747558e..11e22fec 100644 --- a/src/gamspy/_container.py +++ b/src/gamspy/_container.py @@ -271,6 +271,12 @@ def __init__( self._synch_with_gams(gams_to_gamspy=False) + def __enter__(self): + gp._ctx_manager = self + + def __exit__(self, exc_type, exc_val, exc_tb): + gp._ctx_manager = None + def __repr__(self) -> str: return f"Container(system_directory='{self.system_directory}', working_directory='{self.working_directory}', debugging_level='{self._debugging_level}')" diff --git a/src/gamspy/_model.py b/src/gamspy/_model.py index dfcf07c6..2c641f30 100644 --- a/src/gamspy/_model.py +++ b/src/gamspy/_model.py @@ -231,7 +231,7 @@ class Model: def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, problem: Problem | str = Problem.MIP, equations: Iterable[Equation] = [], @@ -249,7 +249,10 @@ def __init__( else: self.name = self._auto_id - self.container = container + self.container: Container = ( + container if container is not None else gp._ctx_manager # type: ignore + ) + assert self.container is not None self._matches = matches self.problem, self.sense = validation.validate_model( equations, problem, sense @@ -275,8 +278,8 @@ def __init__( if not self.equations and not self._matches: raise ValidationError("Model requires at least one equation.") - self._external_module_file = None - self._external_module = None + self._external_module_file: str | None = None + self._external_module: str | None = None if external_module is not None: self.external_module = external_module diff --git a/src/gamspy/_symbols/alias.py b/src/gamspy/_symbols/alias.py index f739c58e..49725cd4 100644 --- a/src/gamspy/_symbols/alias.py +++ b/src/gamspy/_symbols/alias.py @@ -75,11 +75,13 @@ def _constructor_bypass( def __new__( cls, - container: Container, + container: Container | None = None, name: str | None = None, alias_with: Set | Alias | None = None, ): - if not isinstance(container, gp.Container): + ctx = gp._ctx_manager if gp._ctx_manager is not None else None + + if ctx is None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" @@ -91,27 +93,35 @@ def __new__( ) if name is None: - return object.__new__(cls) + obj = object.__new__(cls) + + if container is None: + obj._ctx = ctx + return obj else: if not isinstance(name, str): raise TypeError( f"Name must of type `str` but found {type(name)}" ) try: - symobj = container[name] - if isinstance(symobj, cls): - return symobj + symbol = ctx[name] if ctx is not None else container[name] # type: ignore + if isinstance(symbol, cls): + return symbol raise TypeError( f"Cannot overwrite symbol `{name}` in container" " because it is not an Alias object)" ) except KeyError: - return object.__new__(Alias) + obj = object.__new__(cls) + + if container is None: + obj._ctx = ctx + return obj def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, alias_with: Set | Alias = None, # type: ignore ): @@ -127,6 +137,10 @@ def __init__( self.modified = True self.alias_with = alias_with else: + if hasattr(self, "_ctx"): + container = self._ctx + assert container is not None + if name is not None: name = validation.validate_name(name) else: diff --git a/src/gamspy/_symbols/equation.py b/src/gamspy/_symbols/equation.py index 2608c385..f5a277f2 100644 --- a/src/gamspy/_symbols/equation.py +++ b/src/gamspy/_symbols/equation.py @@ -157,7 +157,7 @@ def _constructor_bypass( def __new__( cls, - container: Container, + container: Container | None = None, name: str | None = None, type: str | EquationType = "regular", domain: list[Set | Alias | str] | Set | Alias | str | None = None, @@ -169,20 +169,26 @@ def __new__( is_miro_output: bool = False, definition_domain: list | None = None, ): - if not isinstance(container, gp.Container): + ctx = gp._ctx_manager if gp._ctx_manager is not None else None + + if ctx is None and not isinstance(container, gp.Container): raise TypeError( f"Container must of type `Container` but found {container}" ) if name is None: - return object.__new__(cls) + obj = object.__new__(cls) + + if container is None: + obj._ctx = ctx + return obj else: if not isinstance(name, str): raise TypeError( f"Name must of type `str` but found {builtins.type(name)}" ) try: - symbol = container[name] + symbol = ctx[name] if ctx is not None else container[name] # type: ignore if isinstance(symbol, cls): return symbol @@ -191,11 +197,15 @@ def __new__( " because it is not an Equation object)" ) except KeyError: - return object.__new__(cls) + obj = object.__new__(cls) + + if container is None: + obj._ctx = ctx + return obj def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, type: str | EquationType = "regular", domain: list[Set | Alias | str] | Set | Alias | str | None = None, @@ -272,6 +282,10 @@ def __init__( self.container._options.miro_protect = previous_state else: + if hasattr(self, "_ctx"): + container = self._ctx + assert container is not None + type = cast_type(type) if name is not None: diff --git a/src/gamspy/_symbols/parameter.py b/src/gamspy/_symbols/parameter.py index 7b4ccdb1..6d32be90 100644 --- a/src/gamspy/_symbols/parameter.py +++ b/src/gamspy/_symbols/parameter.py @@ -111,7 +111,7 @@ def _constructor_bypass( def __new__( cls, - container: Container, + container: Container | None = None, name: str | None = None, domain: list[Set | Alias | str] | Set @@ -127,14 +127,20 @@ def __new__( is_miro_output: bool = False, is_miro_table: bool = False, ): - if not isinstance(container, gp.Container): + ctx = gp._ctx_manager if gp._ctx_manager is not None else None + + if ctx is None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" ) if name is None: - return object.__new__(cls) + obj = object.__new__(cls) + + if container is None: + obj._ctx = ctx + return obj else: if not isinstance(name, str): raise TypeError( @@ -142,7 +148,7 @@ def __new__( ) try: - symbol = container[name] + symbol = ctx[name] if ctx is not None else container[name] # type: ignore if isinstance(symbol, cls): return symbol @@ -151,11 +157,15 @@ def __new__( " because it is not a Parameter object" ) except KeyError: - return object.__new__(cls) + obj = object.__new__(cls) + + if container is None: + obj._ctx = ctx + return obj def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, domain: list[Set | Alias | str] | Set @@ -228,6 +238,10 @@ def __init__( self.setRecords(records, uels_on_axes=uels_on_axes) self.container._options.miro_protect = previous_state else: + if hasattr(self, "_ctx"): + container = self._ctx + assert container is not None + if name is not None: name = validation.validate_name(name) diff --git a/src/gamspy/_symbols/set.py b/src/gamspy/_symbols/set.py index da0383f1..db1adb04 100644 --- a/src/gamspy/_symbols/set.py +++ b/src/gamspy/_symbols/set.py @@ -508,7 +508,7 @@ def _constructor_bypass( def __new__( cls, - container: Container, + container: Container | None = None, name: str | None = None, domain: list[Set | Alias | str] | Set | Alias | str | None = None, is_singleton: bool = False, @@ -519,21 +519,27 @@ def __new__( is_miro_input: bool = False, is_miro_output: bool = False, ): - if not isinstance(container, gp.Container): + ctx = gp._ctx_manager if gp._ctx_manager is not None else None + + if ctx is None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" ) if name is None: - return object.__new__(cls) + obj = object.__new__(cls) + + if container is None: + obj._ctx = ctx + return obj else: if not isinstance(name, str): raise TypeError( f"Name must of type `str` but found {type(name)}" ) try: - symbol = container[name] + symbol = ctx[name] if ctx is not None else container[name] # type: ignore if isinstance(symbol, cls): return symbol @@ -542,11 +548,15 @@ def __new__( " because it is not a Set object)" ) except KeyError: - return object.__new__(cls) + obj = object.__new__(cls) + + if container is None: + obj._ctx = ctx + return obj def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, domain: list[Set | Alias | str] | Set | Alias | str | None = None, is_singleton: bool = False, @@ -619,6 +629,10 @@ def __init__( self.container._options.miro_protect = previous_state else: + if hasattr(self, "_ctx"): + container = self._ctx + assert container is not None + self.where = condition.Condition(self) if name is not None: @@ -629,7 +643,6 @@ def __init__( name = "s" + str(uuid.uuid4()).replace("-", "_") self._singleton_check(is_singleton, records, domain) - previous_state = container._options.miro_protect container._options.miro_protect = False diff --git a/src/gamspy/_symbols/variable.py b/src/gamspy/_symbols/variable.py index cb4a0968..1ed1288a 100644 --- a/src/gamspy/_symbols/variable.py +++ b/src/gamspy/_symbols/variable.py @@ -145,7 +145,7 @@ def _constructor_bypass( def __new__( cls, - container: Container, + container: Container | None = None, name: str | None = None, type: str = "free", domain: list[Set | Alias | str] @@ -160,21 +160,27 @@ def __new__( uels_on_axes: bool = False, is_miro_output: bool = False, ): - if not isinstance(container, gp.Container): + ctx = gp._ctx_manager if gp._ctx_manager is not None else None + + if ctx is None and not isinstance(container, gp.Container): invalid_type = builtins.type(container) raise TypeError( f"Container must of type `Container` but found {invalid_type}" ) if name is None: - return object.__new__(cls) + obj = object.__new__(cls) + + if container is None: + obj._ctx = ctx + return obj else: if not isinstance(name, str): raise TypeError( f"Name must of type `str` but found {builtins.type(name)}" ) try: - symbol = container[name] + symbol = ctx[name] if ctx is not None else container[name] # type: ignore if isinstance(symbol, cls): return symbol @@ -183,11 +189,15 @@ def __new__( " because it is not a Variable object)" ) except KeyError: - return object.__new__(cls) + obj = object.__new__(cls) + + if container is None: + obj._ctx = ctx + return obj def __init__( self, - container: Container, + container: Container | None = None, name: str | None = None, type: str = "free", domain: list[Set | Alias | str] @@ -265,6 +275,10 @@ def __init__( self.container._options.miro_protect = previous_state else: + if hasattr(self, "_ctx"): + container = self._ctx + assert container is not None + type = cast_type(type) if name is not None: diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index 4bc12c92..d57e3ca3 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -1144,6 +1144,82 @@ def test_validation_3(): z.up[vk[v, k]] = n[k] +def test_context_manager(): + m, canning_plants, markets, capacities, demands, distances = data + with m: + # Set + i = Set( + name="i", + records=canning_plants, + description="canning plants", + ) + j = Set( + name="j", + records=markets, + description="markets", + ) + + # Data + a = Parameter( + name="a", + domain=i, + records=capacities, + description="capacity of plant i in cases", + ) + b = Parameter( + name="b", + domain=j, + records=demands, + description="demand at market j in cases", + ) + d = Parameter( + name="d", + domain=[i, j], + records=distances, + description="distance in thousands of miles", + ) + c = Parameter( + name="c", + domain=[i, j], + description="transport cost in thousands of dollars per case", + ) + c[i, j] = 90 * d[i, j] / 1000 + + # Variable + x = Variable( + name="x", + domain=[i, j], + type="Positive", + description="shipment quantities in cases", + ) + + # Equation + supply = Equation( + name="supply", + domain=i, + description="observe supply limit at plant i", + ) + demand = Equation( + name="demand", domain=j, description="satisfy demand at market j" + ) + + supply[i] = Sum(j, x[i, j]) <= a[i] + demand[j] = Sum(i, x[i, j]) >= b[j] + + transport = Model( + name="transport", + equations=m.getEquations(), + problem="LP", + sense=Sense.MIN, + objective=Sum((i, j), c[i, j] * x[i, j]), + ) + transport.solve() + + import math + + assert math.isclose(transport.objective_value, 153.675000, rel_tol=0.001) + + def test_after_exception(data): m, *_ = data x = Variable(m, "x", type="positive") From 4e24485204c8fcefaa88b8940d55a4c6d6adadf5 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Sun, 5 Jan 2025 13:10:50 +0300 Subject: [PATCH 083/135] fix context manager test --- tests/integration/test_solve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index d57e3ca3..5d93a85c 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -1144,7 +1144,7 @@ def test_validation_3(): z.up[vk[v, k]] = n[k] -def test_context_manager(): +def test_context_manager(data): m, canning_plants, markets, capacities, demands, distances = data with m: # Set From 87d2affd8190b658dd02de38c9ff6e56c2dcf424 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Sun, 5 Jan 2025 12:21:15 +0100 Subject: [PATCH 084/135] improve points_to_intervals to avoid redundant intervals --- src/gamspy/formulations/piecewise.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index fc4f0d6e..148b8a0d 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -306,18 +306,20 @@ def points_to_intervals( result: list[ tuple[int | float, int | float, int | float, int | float] ] = [] - finished_at_disc = False + finished_at_disc = True for i in range(len(x_points) - 1): - finished_at_disc = False x1 = x_points[i] x2 = x_points[i + 1] y1 = y_points[i] y2 = y_points[i + 1] if i in discontinuous_points: - result.append((x1, x1, 0, y1)) + if finished_at_disc: + result.append((x1, x1, 0, y1)) + finished_at_disc = True else: + finished_at_disc = False slope = (y2 - y1) / (x2 - x1) offset = y1 - (slope * x1) result.append((x1, x2, slope, offset)) From ee32c8eb8aa2e168124a683296b1e80112e444c2 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 10:54:34 +0300 Subject: [PATCH 085/135] fix short option issue --- src/gamspy/_cli/cli.py | 31 ++++++++++++++----------------- src/gamspy/_cli/install.py | 2 +- src/gamspy/_cli/uninstall.py | 2 +- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/gamspy/_cli/cli.py b/src/gamspy/_cli/cli.py index 66a6b5e7..1cf0493f 100644 --- a/src/gamspy/_cli/cli.py +++ b/src/gamspy/_cli/cli.py @@ -2,7 +2,7 @@ import os import subprocess -from typing import Annotated, Union +from typing import Optional import typer @@ -39,20 +39,18 @@ def version_callback(value: bool): except ModuleNotFoundError: ... - typer.Exit() + raise typer.Exit() @app.callback() def callback( - version: Annotated[ - Union[bool, None], - typer.Option( - "--version", - "-v", - help="Shows the version of gamspy, gamsapi, and gamspy_base.", - callback=version_callback, - ), - ] = None, + version: Optional[bool] = typer.Option( + None, + "-v", + "--version", + help="Shows the version of gamspy, gamsapi, and gamspy_base.", + callback=version_callback, + ), ) -> None: """ GAMSPy CLI - The [bold]gamspy[/bold] command line app. 😎 @@ -65,12 +63,11 @@ def callback( @app.command(short_help="To probe node information.") def probe( - json_out: Annotated[ - Union[str, None], - typer.Option( - "--json-out", "-j", help="Output path for the JSON file." - ) - ] = None, + json_out: Optional[str] = typer.Option( + None, + "--json-out", "-j", + help="Output path for the JSON file." + ), ): gamspy_base_dir = utils._get_gamspy_base_directory() process = subprocess.run( diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index 81a547f8..badd8726 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -143,7 +143,7 @@ def append_dist_info(files, gamspy_base_dir: str): def solver( solver_names: Annotated[ Union[List[str], None], - typer.Argument(default=None, help="solver names to be installed") + typer.Argument(help="solver names to be installed") ] = None, install_all_solvers: Annotated[ Union[bool, None], diff --git a/src/gamspy/_cli/uninstall.py b/src/gamspy/_cli/uninstall.py index bcae8dbf..a4e294af 100644 --- a/src/gamspy/_cli/uninstall.py +++ b/src/gamspy/_cli/uninstall.py @@ -32,7 +32,7 @@ def license(): def solver( solver_names: Annotated[ Union[List[str], None], - typer.Argument(default=None, help="solver names to be uninstalled") + typer.Argument(help="solver names to be uninstalled") ] = None, uninstall_all_solvers: Annotated[ Union[bool, None], From 6e92e9b98d8da15496c3730f3f6285fe6904f97d Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 11:05:13 +0300 Subject: [PATCH 086/135] fix multi value install solver --- src/gamspy/_cli/install.py | 36 ++++++++++++++++++++---------------- src/gamspy/_cli/uninstall.py | 27 +++++++++++++++------------ 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index badd8726..6198ae69 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -141,22 +141,26 @@ def append_dist_info(files, gamspy_base_dir: str): help="[bold][yellow]Examples[/yellow][/bold]: gamspy install solver " ) def solver( - solver_names: Annotated[ - Union[List[str], None], - typer.Argument(help="solver names to be installed") - ] = None, - install_all_solvers: Annotated[ - Union[bool, None], - typer.Option("--install-all-solvers", help="Installs all available add-on solvers.") - ] = None, - existing_solvers: Annotated[ - Union[bool, None], - typer.Option("--existing-solvers", help="Reinstalls previously installed add-on solvers.") - ] = None, - skip_pip_install: Annotated[ - Union[bool, None], - typer.Option("--skip-pip-install", "-s", help="If you already have the solver installed, skip pip install and update gamspy installed solver list.") - ] = None + solver_names: list[str] = typer.Argument( + None, + help="solver names to be installed" + ), + install_all_solvers: bool = typer.Option( + False, + "--install-all-solvers", + help="Installs all available add-on solvers." + ), + existing_solvers: bool = typer.Option( + False, + "--existing-solvers", + help="Reinstalls previously installed add-on solvers." + ), + skip_pip_install: bool = typer.Option( + False, + "--skip-pip-install", + "-s", + help="If you already have the solver installed, skip pip install and update gamspy installed solver list." + ) ): try: import gamspy_base diff --git a/src/gamspy/_cli/uninstall.py b/src/gamspy/_cli/uninstall.py index a4e294af..97229c70 100644 --- a/src/gamspy/_cli/uninstall.py +++ b/src/gamspy/_cli/uninstall.py @@ -30,18 +30,21 @@ def license(): short_help="To uninstall solvers" ) def solver( - solver_names: Annotated[ - Union[List[str], None], - typer.Argument(help="solver names to be uninstalled") - ] = None, - uninstall_all_solvers: Annotated[ - Union[bool, None], - typer.Option("--uninstall-all-solvers", help="Uninstalls all add-on solvers.") - ] = None, - skip_pip_uninstall: Annotated[ - Union[bool, None], - typer.Option("--skip-pip-install", "-s", help="If you already have the solver uninstalled, skip pip uninstall and update gamspy installed solver list.") - ] = None + solver_names: list[str] = typer.Argument( + None, + help="solver names to be uninstalled" + ), + uninstall_all_solvers: bool = typer.Option( + False, + "--uninstall-all-solvers", + help="Uninstalls all add-on solvers." + ), + skip_pip_uninstall: bool = typer.Option( + False, + "--skip-pip-install", + "-s", + help="If you already have the solver uninstalled, skip pip uninstall and update gamspy installed solver list." + ) ): try: import gamspy_base From 983ca360ff3b26904a8d35e162a507f27c2212b7 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 11:15:33 +0300 Subject: [PATCH 087/135] fix the short command of list solvers, retrieve and run --- src/gamspy/_cli/list.py | 20 ++++++++++------- src/gamspy/_cli/retrieve.py | 34 +++++++++++++--------------- src/gamspy/_cli/run.py | 45 +++++++++++++++++++------------------ 3 files changed, 51 insertions(+), 48 deletions(-) diff --git a/src/gamspy/_cli/list.py b/src/gamspy/_cli/list.py index a8c740de..842c0be7 100644 --- a/src/gamspy/_cli/list.py +++ b/src/gamspy/_cli/list.py @@ -21,14 +21,18 @@ @app.command() def solvers( - all: Annotated[ - Union[bool, None], - typer.Option("--all", "-a", help="Shows all available solvers."), - ] = None, - defaults: Annotated[ - Union[bool, None], - typer.Option("--defaults", "-d", help="Shows default solvers."), - ] = None, + all: bool = typer.Option( + False, + "--all", + "-a", + help="Shows all available solvers." + ), + defaults: bool = typer.Option( + False, + "--defaults", + "-d", + help="Shows default solvers." + ), ) -> None: try: import gamspy_base diff --git a/src/gamspy/_cli/retrieve.py b/src/gamspy/_cli/retrieve.py index 10017c69..6ce14fb9 100644 --- a/src/gamspy/_cli/retrieve.py +++ b/src/gamspy/_cli/retrieve.py @@ -22,24 +22,22 @@ help="[bold][yellow]Examples[/yellow][/bold]: gamspy retrieve license [--input .json] [--output .json]" ) def license( - access_code: Annotated[ - str, - typer.Argument(help="Access code of the license."), - ], - input: Annotated[ - Union[str, None], - typer.Option( - "--input", - "-i", - help="Input json file path to retrieve the license based on the node information.", - ), - ] = None, - output: Annotated[ - Union[str, None], - typer.Option( - "--output", "-o", help="Output path for the license file." - ), - ] = None, + access_code: str = typer.Argument( + ..., + help="Access code of the license." + ), + input: str = typer.Option( + None, + "--input", + "-i", + help="Input json file path to retrieve the license based on the node information." + ), + output: str = typer.Option( + None, + "--output", + "-o", + help="Output path for the license file." + ), ) -> None: if input is None or not os.path.isfile(input): raise ValidationError( diff --git a/src/gamspy/_cli/run.py b/src/gamspy/_cli/run.py index 4df95868..96ef6800 100644 --- a/src/gamspy/_cli/run.py +++ b/src/gamspy/_cli/run.py @@ -27,28 +27,29 @@ class ModeEnum(Enum): short_help="Runs a GAMSPY model with GAMS MIRO app." ) def miro( - model: Annotated[ - Union[str, None], - typer.Option("--model", "-g", - help="Path to the GAMSPy model." - ) - ] = None, - mode: Annotated[ - ModeEnum, - typer.Option("--mode", "-m", - help="Execution mode of MIRO" - ) - ] = "base", # type: ignore - path: Annotated[ - Union[str, None], - typer.Option("--path", "-p", - help="Path to the MIRO executable (.exe on Windows, .app on macOS or .AppImage on Linux" - ) - ] = None, - skip_execution: Annotated[ - Union[bool, None], - typer.Option("--skip-execution", help="Whether to skip model execution.") - ] = None + model: str = typer.Option( + None, + "--model", + "-g", + help="Path to the GAMSPy model." + ), + mode: ModeEnum = typer.Option( + "base", + "--mode", + "-m", + help="Execution mode of MIRO" + ), + path: str = typer.Option( + None, + "--path", + "-p", + help="Path to the MIRO executable (.exe on Windows, .app on macOS or .AppImage on Linux" + ), + skip_execution: bool = typer.Option( + False, + "--skip-execution", + help="Whether to skip model execution." + ) ) -> None: if model is None: raise ValidationError("--model must be provided to run MIRO") From c2e727bf28513f98d415efef6bd1401669123d7b Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 11:46:52 +0300 Subject: [PATCH 088/135] update docs --- docs/cli/index.rst | 82 +++++++++------------------------------ docs/cli/install.rst | 75 +++++++++++++++++++++++------------- docs/cli/list.rst | 87 ++++++++++++++++++++++++++++++------------ docs/cli/probe.rst | 15 +++++--- docs/cli/retrieve.rst | 59 +++++++++++----------------- docs/cli/run.rst | 61 +++++++++++++++++++---------- docs/cli/show.rst | 39 ++++++++++++------- docs/cli/uninstall.rst | 56 ++++++++++++++++++--------- 8 files changed, 264 insertions(+), 210 deletions(-) diff --git a/docs/cli/index.rst b/docs/cli/index.rst index 621cb8dd..1c681cda 100644 --- a/docs/cli/index.rst +++ b/docs/cli/index.rst @@ -25,78 +25,30 @@ easily install solvers, licenses and much more. - - Shows the version of GAMSPy, GAMS and gamspy_base -Example: :: +Example +------- + +Show help message:: $ gamspy --help - usage: gamspy [-h] [-v] - gamspy install license or [--uses-port ] - gamspy uninstall license - gamspy install solver [--skip-pip-install] [--existing-solvers] [--install-all-solvers] - gamspy uninstall solver [--skip-pip-uninstall] [--uninstall-all-solvers] - gamspy list solvers [--all] - gamspy show license - gamspy show base - gamspy probe [-j ] - gamspy retrieve license [--input ] [--output ] - gamspy run miro [--path ] [--model ] + Usage: gamspy [OPTIONS] COMMAND [ARGS]... GAMSPy CLI - options: - -h, --help show this help message and exit - -v, --version Shows the version of GAMSPy, GAMS and gamspy_base - - gamspy install license or : - Options for installing a license. - - --uses-port USES_PORT - Interprocess communication starting port. - - gamspy uninstall license: - Command to uninstall user license. - - gamspy install solver : - Options for installing solvers - - --skip-pip-install, -s - If you already have the solver installed, skip pip install and update gamspy installed solver list. - - gamspy uninstall solver : - Options for uninstalling solvers - - --skip-pip-uninstall, -u - If you don't want to uninstall the package of the solver, skip uninstall and update gamspy installed solver list. - - gamspy list solvers: - `gamspy list solvers` options - - -a, --all Shows all available solvers. - - gamspy probe: - `gamspy probe` options - - --json-out JSON_OUT, -j JSON_OUT - Output path for the json file. - - gamspy retrieve license : - `gamspy retrieve license` options - - --output OUTPUT, -o OUTPUT - Output path for the license file. - --input INPUT, -i INPUT - json file path to retrieve a license based on node information. - - gamspy run miro: - `gamspy run miro` options + Options: + -h, --help Show this message and exit. + -v, --version Shows the version of GAMSPy, GAMS and gamspy_base - -g MODEL, --model MODEL - Path to the gamspy model - -m {config,base,deploy}, --mode {config,base,deploy} - Execution mode of MIRO - -p PATH, --path PATH Path to the MIRO executable (.exe on Windows, .app on macOS or .AppImage on Linux) - --skip-execution Whether to skip model execution + Commands: + install To install licenses and solvers. + list To list solvers. + probe To probe node information for license retrieval. + retrieve To retrieve a license with another node's information. + run To run your model with GAMS MIRO. + show To show your license and gamspy_base directory. + uninstall To uninstall licenses and solvers. -:: +Show version information:: $ gamspy --version GAMSPy version: 0.14.6 diff --git a/docs/cli/install.rst b/docs/cli/install.rst index 33dfa355..3eed1321 100644 --- a/docs/cli/install.rst +++ b/docs/cli/install.rst @@ -5,12 +5,17 @@ gamspy install Installs add-on solvers or a license to the GAMSPy installation. +Install License +------------- + +Installs a new license using either an access code or a license file. + Usage ------ +~~~~~ :: - gamspy install solver [OPTIONS] + gamspy install license | [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -20,33 +25,33 @@ Usage - Short - Default - Description - * - -\-skip-pip-install - - -s - - - - Skips the pip install command in case the package was manually installed. - * - -\-install-all-solvers - - - - - - Installs all add-on solvers. - * - -\-existing-solvers - - + * - -\-uses-port - - - Installs add-on solvers that were previously installed with an older version of gamspy. + - None + - Interprocess communication starting port. Only relevant for local licenses that restrict concurrent use of GAMSPy. -Example 1: :: +Examples +~~~~~~~~ - $ gamspy install solver mosek conopt xpress +Install using access code:: -Example 2: :: + $ gamspy install license 876e5812-1222-4aba-819d-e1e91b7e2f52 - $ gamspy install solver --install-all-solvers +Install using license file:: + + $ gamspy install license /home/joe/gamslice.txt + +Install Solver +------------- + +Installs one or more solvers to the GAMSPy installation. Usage ------ +~~~~~ :: - gamspy install license | [OPTIONS] + gamspy install solver [solver_name(s)] [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -56,16 +61,34 @@ Usage - Short - Default - Description - * - -\-uses-port - - -u + * - -\-skip-pip-install + - -s + - False + - If you already have the solver installed, skip pip install and update gamspy installed solver list. + * - -\-install-all-solvers - - - Interprocess communication starting port. Only relevant for local licenses that restrict concurrent use of GAMSPy. + - False + - Installs all available add-on solvers. + * - -\-existing-solvers + - + - False + - Reinstalls previously installed add-on solvers. +Examples +~~~~~~~~ -Example: :: +Install specific solvers:: - $ gamspy install license 876e5812-1222-4aba-819d-e1e91b7e2f52 + $ gamspy install solver mosek conopt xpress -:: +Install all available solvers:: - $ gamspy install license /home/joe/gamslice.txt + $ gamspy install solver --install-all-solvers + +Reinstall previously installed solvers:: + + $ gamspy install solver --existing-solvers + +Skip pip installation:: + + $ gamspy install solver mosek -s diff --git a/docs/cli/list.rst b/docs/cli/list.rst index 2c930180..20247dea 100644 --- a/docs/cli/list.rst +++ b/docs/cli/list.rst @@ -20,10 +20,17 @@ Usage - Description * - -\-all - -a - - + - False - Shows all available solvers that can be installed. + * - -\-defaults + - -d + - False + - Shows default solvers for each problem type. -Example: :: +Examples +-------- + +List installed solvers:: $ gamspy list solvers Installed Solvers @@ -31,36 +38,66 @@ Example: :: CONOPT, CONVERT, CPLEX, IPOPT, IPOPTH, KESTREL, NLPEC, PATH, SHOT Model types that can be solved with the installed solvers - ========================================================= - CONOPT : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - CONVERT : LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP, RMIQCP, EMP - CPLEX : LP, MIP, RMIP, QCP, MIQCP, RMIQCP - IPOPT : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - IPOPTH : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - KESTREL : LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP, RMIQCP, EMP - NLPEC : MCP, MPEC, RMPEC - PATH : MCP, CNS - SHOT : MINLP, MIQCP + ======================================================= + ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ Solver ┃ Problem Types ┃ + ┡━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ + │ CONOPT │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ CONVERT │ LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP │ + │ CPLEX │ LP, MIP, RMIP, QCP, MIQCP, RMIQCP │ + │ IPOPT │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ IPOPTH │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ KESTREL │ LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP │ + │ NLPEC │ MCP, MPEC, RMPEC │ + │ PATH │ MCP, CNS │ + │ SHOT │ MINLP, MIQCP │ + └─────────┴────────────────────────────────────────────────────────────────────────────┘ -:: +List all available solvers:: - $ gamspy list solvers -a + $ gamspy list solvers --all Available Solvers ================= BARON, CBC, CONOPT, CONOPT3, CONVERT, COPT, CPLEX, DICOPT, EXAMINER, EXAMINER2, GUROBI, HIGHS, IPOPT, IPOPTH, KESTREL, KNITRO, MILES, MINOS, MOSEK, MPSGE, NLPEC, PATH, PATHNLP, SBB, SCIP, SHOT, SNOPT, SOPLEX, XPRESS - Model types that can be solved with the installed solvers: + Model types that can be solved with the installed solvers + ======================================================= + ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ Solver ┃ Problem Types ┃ + ┡━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ + │ CONOPT │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ CONVERT │ LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP│ + │ CPLEX │ LP, MIP, RMIP, QCP, MIQCP, RMIQCP │ + │ IPOPT │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ IPOPTH │ LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP │ + │ KESTREL │ LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP│ + │ NLPEC │ MCP, MPEC, RMPEC │ + │ PATH │ MCP, CNS │ + │ SHOT │ MINLP, MIQCP │ + └─────────┴───────────────────────────────────────────────────────────────────────────┘ + +List default solvers for each problem type:: - CONOPT : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - CONVERT : LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP, RMIQCP, EMP - CPLEX : LP, MIP, RMIP, QCP, MIQCP, RMIQCP - IPOPT : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - IPOPTH : LP, RMIP, NLP, CNS, DNLP, RMINLP, QCP, RMIQCP - KESTREL : LP, MIP, RMIP, NLP, MCP, MPEC, RMPEC, CNS, DNLP, RMINLP, MINLP, QCP, MIQCP, RMIQCP, EMP - NLPEC : MCP, MPEC, RMPEC - PATH : MCP, CNS - SHOT : MINLP, MIQCP + $ gamspy list solvers --defaults + ┏━━━━━━━━━┳━━━━━━━━┓ + ┃ Problem ┃ Solver ┃ + ┡━━━━━━━━━╇━━━━━━━━┩ + │ LP │ CPLEX │ + │ MIP │ CPLEX │ + │ RMIP │ CPLEX │ + │ NLP │ CONOPT │ + │ MCP │ PATH │ + │ MPEC │ NLPEC │ + │ CNS │ PATH │ + │ DNLP │ CONOPT │ + │ RMINLP │ CONOPT │ + │ MINLP │ SHOT │ + │ QCP │ CPLEX │ + │ MIQCP │ CPLEX │ + │ RMIQCP │ CPLEX │ + └─────────┴────────┘ .. note:: - The possible model types for a solver become available after the solver has been installed. \ No newline at end of file + The possible model types for a solver become available after the solver has been installed. + For a complete list of solvers and their capabilities, visit: https://www.gams.com/latest/docs/S_MAIN.html#SOLVERS_MODEL_TYPES \ No newline at end of file diff --git a/docs/cli/probe.rst b/docs/cli/probe.rst index e58e7a48..dc7c00c1 100644 --- a/docs/cli/probe.rst +++ b/docs/cli/probe.rst @@ -4,11 +4,11 @@ gamspy probe Probes the node (computer) to get information about the node for fingerprinting the license. Usage ------ +~~~~~ :: - gamspy probe -j info.json + gamspy probe [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -20,12 +20,15 @@ Usage - Description * - -\-json-out - -j - - + - None - Output path to dump probed information. -Example: :: +Example +~~~~~~~ - $ gamspy probe -o info.json +:: + + $ gamspy probe -j info.json { "cpu_id": "27197016915918185882701231384169", "device_id": "18113801", @@ -45,4 +48,4 @@ Example: :: } .. note:: - The probed information is always written to standard output. The ``-o`` option will write a file in addition. \ No newline at end of file + The probed information is always written to standard output. The ``-j`` option will write the information to a JSON file. \ No newline at end of file diff --git a/docs/cli/retrieve.rst b/docs/cli/retrieve.rst index 1f3deb44..56f051dc 100644 --- a/docs/cli/retrieve.rst +++ b/docs/cli/retrieve.rst @@ -1,16 +1,21 @@ .. _gamspy_retrieve: gamspy retrieve -=============== +============== -Retrieves the license based on given probed information. +Retrieves a license with another node's information. + +Retrieve License +-------------- + +Retrieves a license using an access code and node information from a JSON file. Usage ------ +~~~~~ :: - gamspy retrieve license [-i ] [-o ] + gamspy retrieve license [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -20,43 +25,25 @@ Usage - Short - Default - Description - * - -\-input + * - -\-input - -i - - - - Input path to the file with probed node information, potentially from a different node not connected to the internet/ - * - -\-output + - None + - Input JSON file path to retrieve the license based on the node information. + * - -\-output - -o - - standard output - - Output path to write the license file. + - None + - Output path for the license file. -Example: :: +Examples +~~~~~~~~ - $ gamspy retrieve license 876e5812-1222-4aba-819d-e1e91b7e2f52 - Joe____________________________________________G240827+0003Ac-GEN - joe@my.mail.com__________________________________________________ - 07CPMK___________________________________________________________ - 0COCOC___________________________________________________________ - CLA100251_876e5812-1222-4aba-819d-e1e91b7e2f52_O_FREEACADEMIC____ - node:18113801____________________________________________________ - MEYCIQDXZ42fd7G8MCppt6NXluallrcGdSiZRqFg9gbPxYBq1QIhAIZ7SvetdxRGj - U0Piwc6zVAc0d/2pjm3iM70/mWToOSl__________________________________ +Retrieve a license with node information:: -:: + $ gamspy retrieve license 876e5812-1222-4aba-819d-e1e91b7e2f52 --input node_info.json + +Retrieve and save the license to a file:: - $ gamspy retrieve license 876e5812-1222-4aba-819d-e1e91b7e2f52 -i info.json -o gamslice.txt - Joe____________________________________________G240827+0003Ac-GEN - joe@my.mail.com__________________________________________________ - 07CPMK___________________________________________________________ - 0COCOC___________________________________________________________ - CLA100251_876e5812-1222-4aba-819d-e1e91b7e2f52_O_FREEACADEMIC____ - node:18113801____________________________________________________ - MEYCIQDXZ42fd7G8MCppt6NXluallrcGdSiZRqFg9gbPxYBq1QIhAIZ7SvetdxRGj - U0Piwc6zVAc0d/2pjm3iM70/mWToOSl__________________________________ + $ gamspy retrieve license 876e5812-1222-4aba-819d-e1e91b7e2f52 --input node_info.json --output license.txt .. note:: - The CLI tool ``gamspy retrieve license`` works together with ``gamspy probe`` and ``gamspy install license``. It's main purpose is to get a license - for a node (or machine or computer) that is not connected to the internet and not capable of reaching ``license.gams.com`` to retrieve the - license itself. In this case one runs ``gamspy probe -o info.json`` on the machine not connected to the internet, let's call this machine A. - Now, we bring the file ``info.json`` to a machine connected to the internet, let's call this machine B. On machine B, one runs now - ``gamspy retrieve license -i info.json -o gamslice.A``. Now we bring the file ``gamslice.A`` to machine A and run on machine A - ``gams install license /path/to/gamslice.A``. \ No newline at end of file + The input JSON file should contain the node information required for license retrieval. \ No newline at end of file diff --git a/docs/cli/run.rst b/docs/cli/run.rst index bcacb41a..0814e334 100644 --- a/docs/cli/run.rst +++ b/docs/cli/run.rst @@ -1,15 +1,19 @@ gamspy run -========== +========= -Runs the GAMS MIRO application. +Runs GAMSPy models with GAMS MIRO. + +Run with MIRO +------------ + +Runs a GAMSPy model with GAMS MIRO application. Usage ------ +~~~~~ :: - gamspy run miro [OPTIONS] - + gamspy run miro [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -19,23 +23,38 @@ Usage - Short - Default - Description - * - -\-path - - -p - - - - Path to your GAMS MIRO installation. - * - -\-model - - -g - - - - Path to your model. - * - -\-mode - - -m + * - -\-model + - -g + - None + - Path to the GAMSPy model. + * - -\-mode + - -m - base - - Execution mode of MIRO + - Execution mode of MIRO (config, base, or deploy). + * - -\-path + - -p + - None + - Path to the MIRO executable (.exe on Windows, .app on macOS or .AppImage on Linux). * - -\-skip-execution - - - - - - Whether to skip model execution + - + - False + - Whether to skip model execution. + +Examples +~~~~~~~~ + +Run a model with MIRO:: + + $ gamspy run miro --model transport.py + +Run a model with MIRO in configuration mode:: + + $ gamspy run miro --model transport.py --mode config + +Run a model with MIRO using a specific MIRO executable:: + + $ gamspy run miro --model transport.py --path /path/to/miro.exe -Example: :: +Run a model with MIRO skipping model execution:: - $ gamspy run miro -m config -p "/Applications/GAMS MIRO.app/Contents/MacOS/GAMS MIRO" -g ~/miro_apps/myapp.py \ No newline at end of file + $ gamspy run miro --model transport.py --skip-execution \ No newline at end of file diff --git a/docs/cli/show.rst b/docs/cli/show.rst index ce6962b0..7fd3964b 100644 --- a/docs/cli/show.rst +++ b/docs/cli/show.rst @@ -1,29 +1,42 @@ gamspy show -=========== +========== -Shows the license file or gamspy_base directory. +Shows information about your GAMSPy installation. + +Show License +----------- + +Shows the content of the current license. Usage ------ +~~~~~ :: - gamspy show + gamspy show license -Example: :: +Example:: $ gamspy show license - License found at: /home/joe/venvs/gamspy/lib/python3.12/site-packages/gamspy_base/gamslice.txt - + License found at: /home/user/.gamspy/gamspy_license.txt + License Content =============== - GAMS_Demo,_for_EULA_and_demo_limitations_see___G240530/0001CB-GEN - https://www.gams.com/latest/docs/UG%5FLicense.html_______________ - 1496631900_______________________________________________________ - 0801332905_______________________________________________________ - DC0000_______g_1_______________________________C_Eval____________ + [License content will be displayed here] + +Show Base Directory +----------------- + +Shows the path of the gamspy_base installation directory. + +Usage +~~~~~ :: + gamspy show base + +Example:: + $ gamspy show base - /home/joe/venvs/gamspy/lib/python3.12/site-packages/gamspy_base + /home/user/miniconda3/envs/gamspy/lib/python3.9/site-packages/gamspy_base diff --git a/docs/cli/uninstall.rst b/docs/cli/uninstall.rst index 22b89607..a7f60f32 100644 --- a/docs/cli/uninstall.rst +++ b/docs/cli/uninstall.rst @@ -1,14 +1,35 @@ gamspy uninstall -================ +=============== -Uninstalls an existing solver or a license from the GAMSPy installation. +Uninstalls solvers or the current license from the GAMSPy installation. + +Uninstall License +--------------- + +Uninstalls the current license. + +Usage +~~~~~ + +:: + + gamspy uninstall license + +Example:: + + $ gamspy uninstall license + +Uninstall Solver +-------------- + +Uninstalls one or more solvers from the GAMSPy installation. Usage ------ +~~~~~ :: - gamspy uninstall solver [OPTIONS] + gamspy uninstall solver [solver_name(s)] [OPTIONS] .. list-table:: :widths: 20 20 20 40 @@ -18,28 +39,27 @@ Usage - Short - Default - Description - * - -\-skip-pip-uninstall - - -u - - - - Skips the pip uninstall command in case the package was manually deleted. + * - -\-skip-pip-install + - -s + - False + - If you already have the solver uninstalled, skip pip uninstall and update gamspy installed solver list. * - -\-uninstall-all-solvers - - -u - + - False - Uninstalls all add-on solvers. -Example: :: +Examples +~~~~~~~~ - gamspy uninstall solver mosek +Uninstall specific solvers:: -.. note:: - Default solvers cannot be uninstalled. + $ gamspy uninstall solver mosek conopt -Usage ------ +Uninstall all add-on solvers:: -:: + $ gamspy uninstall solver --uninstall-all-solvers - gamspy uninstall license +Skip pip uninstallation:: -This uninstalls a previously installed license and reinstates the GAMSPy demo license that comes with the GAMSPy installation. + $ gamspy uninstall solver mosek -s From e973b8e7873b9b94951318bb193a0f6384286256 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 11:58:14 +0300 Subject: [PATCH 089/135] update cli docs --- docs/cli/index.rst | 17 +++++++++++++---- src/gamspy/_cli/install.py | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/cli/index.rst b/docs/cli/index.rst index 1c681cda..206cf81f 100644 --- a/docs/cli/index.rst +++ b/docs/cli/index.rst @@ -6,7 +6,8 @@ gamspy ====== GAMSPy comes with a command-line interface (CLI) to allow users to -easily install solvers, licenses and much more. +easily install solvers, licenses and much more. Autocompletion can be +installed for the current shell with `--install-completion`. .. list-table:: :widths: 20 20 20 40 @@ -24,6 +25,14 @@ easily install solvers, licenses and much more. - -v - - Shows the version of GAMSPy, GAMS and gamspy_base + * - -\-install-completion + - + - + - Install completion for the current shell. + * - -\-show-completion + - + - + - Show completion for the current shell, to copy it or customize the installation. Example ------- @@ -51,9 +60,9 @@ Show help message:: Show version information:: $ gamspy --version - GAMSPy version: 0.14.6 - GAMS version: 47.4.1 - gamspy_base version: 47.4.1 + GAMSPy version: 1.4.0 + GAMS version: 48.5.0 + gamspy_base version: 48.5.0 List of Commands ---------------- diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index 6198ae69..6a0e1577 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -52,7 +52,7 @@ def license( ) if request.status != 200: raise ValidationError( - f"License server did not respond in an expected way. Request status: {request.status}. Please try again." + f"License server did not respond in an expected way. Request status: {request.status}. Reason: {request.data.decode('utf-8', errors='replace')}" ) data = request.data.decode("utf-8", errors="replace") From 78ac4a8c53d85d2efd3f95705eca0df890afc2bb Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 6 Jan 2025 10:28:13 +0100 Subject: [PATCH 090/135] remove unnecessary space --- docs/cli/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/index.rst b/docs/cli/index.rst index 206cf81f..5ea058c2 100644 --- a/docs/cli/index.rst +++ b/docs/cli/index.rst @@ -49,7 +49,7 @@ Show help message:: -v, --version Shows the version of GAMSPy, GAMS and gamspy_base Commands: - install To install licenses and solvers. + install To install licenses and solvers. list To list solvers. probe To probe node information for license retrieval. retrieve To retrieve a license with another node's information. From 519a8d50ccc225f3ee8b66e0229546dae9fb875a Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 13:15:18 +0300 Subject: [PATCH 091/135] make container context manager thread safe --- src/gamspy/__init__.py | 2 +- src/gamspy/_container.py | 16 ++- src/gamspy/_model.py | 11 ++- src/gamspy/_symbols/alias.py | 8 +- src/gamspy/_symbols/equation.py | 8 +- src/gamspy/_symbols/parameter.py | 8 +- src/gamspy/_symbols/set.py | 8 +- src/gamspy/_symbols/variable.py | 8 +- tests/integration/test_solve.py | 165 +++++++++++++++++++++++++------ 9 files changed, 192 insertions(+), 42 deletions(-) diff --git a/src/gamspy/__init__.py b/src/gamspy/__init__.py index 38fa2406..15be7123 100644 --- a/src/gamspy/__init__.py +++ b/src/gamspy/__init__.py @@ -36,7 +36,7 @@ from .version import __version__ -_ctx_manager: Container | None = None +_ctx_managers: dict[tuple[int, int], Container] = dict() __all__ = [ "Container", diff --git a/src/gamspy/_container.py b/src/gamspy/_container.py index 11e22fec..38bc88f8 100644 --- a/src/gamspy/_container.py +++ b/src/gamspy/_container.py @@ -7,6 +7,7 @@ import socket import subprocess import tempfile +import threading import time import traceback import uuid @@ -92,7 +93,8 @@ def handler(signum, frame): if platform.system() != "Windows": os.kill(process.pid, signal.SIGINT) - signal.signal(signal.SIGINT, handler) + if threading.current_thread() is threading.main_thread(): + signal.signal(signal.SIGINT, handler) start = time.time() while True: @@ -272,10 +274,18 @@ def __init__( self._synch_with_gams(gams_to_gamspy=False) def __enter__(self): - gp._ctx_manager = self + pid = os.getpid() + tid = threading.get_native_id() + gp._ctx_managers[(pid, tid)] = self def __exit__(self, exc_type, exc_val, exc_tb): - gp._ctx_manager = None + pid = os.getpid() + tid = threading.get_native_id() + + try: + del gp._ctx_managers[(pid, tid)] + except KeyError: + ... def __repr__(self) -> str: return f"Container(system_directory='{self.system_directory}', working_directory='{self.working_directory}', debugging_level='{self._debugging_level}')" diff --git a/src/gamspy/_model.py b/src/gamspy/_model.py index 2c641f30..10aa9cd6 100644 --- a/src/gamspy/_model.py +++ b/src/gamspy/_model.py @@ -4,6 +4,7 @@ import io import logging import os +import threading import uuid from collections.abc import Iterable from enum import Enum @@ -249,9 +250,13 @@ def __init__( else: self.name = self._auto_id - self.container: Container = ( - container if container is not None else gp._ctx_manager # type: ignore - ) + if container is not None: + self.container = container + else: + self.container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + assert self.container is not None self._matches = matches self.problem, self.sense = validation.validate_model( diff --git a/src/gamspy/_symbols/alias.py b/src/gamspy/_symbols/alias.py index 49725cd4..5277389f 100644 --- a/src/gamspy/_symbols/alias.py +++ b/src/gamspy/_symbols/alias.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import threading import uuid from typing import TYPE_CHECKING @@ -79,7 +81,11 @@ def __new__( name: str | None = None, alias_with: Set | Alias | None = None, ): - ctx = gp._ctx_manager if gp._ctx_manager is not None else None + ctx = None + try: + ctx = gp._ctx_managers[(os.getpid(), threading.get_native_id())] + except KeyError: + ... if ctx is None and not isinstance(container, gp.Container): raise TypeError( diff --git a/src/gamspy/_symbols/equation.py b/src/gamspy/_symbols/equation.py index f5a277f2..696ff108 100644 --- a/src/gamspy/_symbols/equation.py +++ b/src/gamspy/_symbols/equation.py @@ -2,6 +2,8 @@ import builtins import itertools +import os +import threading import uuid from enum import Enum from typing import TYPE_CHECKING, Any @@ -169,7 +171,11 @@ def __new__( is_miro_output: bool = False, definition_domain: list | None = None, ): - ctx = gp._ctx_manager if gp._ctx_manager is not None else None + ctx = None + try: + ctx = gp._ctx_managers[(os.getpid(), threading.get_native_id())] + except KeyError: + ... if ctx is None and not isinstance(container, gp.Container): raise TypeError( diff --git a/src/gamspy/_symbols/parameter.py b/src/gamspy/_symbols/parameter.py index 6d32be90..1084dcf8 100644 --- a/src/gamspy/_symbols/parameter.py +++ b/src/gamspy/_symbols/parameter.py @@ -1,6 +1,8 @@ from __future__ import annotations import itertools +import os +import threading import uuid from collections.abc import Sequence from typing import TYPE_CHECKING, Any @@ -127,7 +129,11 @@ def __new__( is_miro_output: bool = False, is_miro_table: bool = False, ): - ctx = gp._ctx_manager if gp._ctx_manager is not None else None + ctx = None + try: + ctx = gp._ctx_managers[(os.getpid(), threading.get_native_id())] + except KeyError: + ... if ctx is None and not isinstance(container, gp.Container): raise TypeError( diff --git a/src/gamspy/_symbols/set.py b/src/gamspy/_symbols/set.py index db1adb04..6f7d7966 100644 --- a/src/gamspy/_symbols/set.py +++ b/src/gamspy/_symbols/set.py @@ -1,6 +1,8 @@ from __future__ import annotations import itertools +import os +import threading import uuid from typing import TYPE_CHECKING, Any, Literal @@ -519,7 +521,11 @@ def __new__( is_miro_input: bool = False, is_miro_output: bool = False, ): - ctx = gp._ctx_manager if gp._ctx_manager is not None else None + ctx = None + try: + ctx = gp._ctx_managers[(os.getpid(), threading.get_native_id())] + except KeyError: + ... if ctx is None and not isinstance(container, gp.Container): raise TypeError( diff --git a/src/gamspy/_symbols/variable.py b/src/gamspy/_symbols/variable.py index 1ed1288a..171ca8c0 100644 --- a/src/gamspy/_symbols/variable.py +++ b/src/gamspy/_symbols/variable.py @@ -2,6 +2,8 @@ import builtins import itertools +import os +import threading import uuid from collections.abc import Sequence from enum import Enum @@ -160,7 +162,11 @@ def __new__( uels_on_axes: bool = False, is_miro_output: bool = False, ): - ctx = gp._ctx_manager if gp._ctx_manager is not None else None + ctx = None + try: + ctx = gp._ctx_managers[(os.getpid(), threading.get_native_id())] + except KeyError: + ... if ctx is None and not isinstance(container, gp.Container): invalid_type = builtins.type(container) diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index 5d93a85c..c186262d 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -12,6 +12,7 @@ import numpy as np import pytest +import gamspy as gp import gamspy._validation as validation import gamspy.math as gamspy_math from gamspy import ( @@ -160,6 +161,95 @@ def transport(f_value): return transport.objective_value +def transport_with_ctx(f_value): + distances = [ + ["seattle", "new-york", 2.5], + ["seattle", "chicago", 1.7], + ["seattle", "topeka", 1.8], + ["san-diego", "new-york", 2.5], + ["san-diego", "chicago", 1.8], + ["san-diego", "topeka", 1.4], + ] + + capacities = [["seattle", 350], ["san-diego", 600]] + demands = [["new-york", 325], ["chicago", 300], ["topeka", 275]] + + m = Container() + with m: + print(gp._ctx_managers) + i = Set( + name="i", + records=["seattle", "san-diego"], + description="canning plants", + ) + j = Set( + name="j", + records=["new-york", "chicago", "topeka"], + description="markets", + ) + + # Data + a = Parameter( + name="a", + domain=i, + records=capacities, + description="capacity of plant i in cases", + ) + b = Parameter( + name="b", + domain=j, + records=demands, + description="demand at market j in cases", + ) + d = Parameter( + name="d", + domain=[i, j], + records=distances, + description="distance in thousands of miles", + ) + c = Parameter( + name="c", + domain=[i, j], + description="transport cost in thousands of dollars per case", + ) + f = Parameter(name="f", records=f_value) + c[i, j] = f * d[i, j] / 1000 + + # Variable + x = Variable( + name="x", + domain=[i, j], + type="Positive", + description="shipment quantities in cases", + ) + + # Equation + supply = Equation( + name="supply", + domain=i, + description="observe supply limit at plant i", + ) + demand = Equation( + name="demand", domain=j, description="satisfy demand at market j" + ) + + supply[i] = Sum(j, x[i, j]) <= a[i] + demand[j] = Sum(i, x[i, j]) >= b[j] + + transport = Model( + name="transport", + equations=m.getEquations(), + problem="LP", + sense=Sense.MIN, + objective=Sum((i, j), c[i, j] * x[i, j]), + ) + transport.solve() + + m.close() + + return transport.objective_value + + def transport2(f_value): m = Container() @@ -1147,37 +1237,12 @@ def test_validation_3(): def test_context_manager(data): m, canning_plants, markets, capacities, demands, distances = data with m: - # Set - i = Set( - name="i", - records=canning_plants, - description="canning plants", - ) - j = Set( - name="j", - records=markets, - description="markets", - ) + i = Set(records=canning_plants) + j = Set(name="j", records=markets) - # Data - a = Parameter( - name="a", - domain=i, - records=capacities, - description="capacity of plant i in cases", - ) - b = Parameter( - name="b", - domain=j, - records=demands, - description="demand at market j in cases", - ) - d = Parameter( - name="d", - domain=[i, j], - records=distances, - description="distance in thousands of miles", - ) + a = Parameter(name="a", domain=i, records=capacities) + b = Parameter(name="b", domain=j, records=demands) + d = Parameter(name="d", domain=[i, j], records=distances) c = Parameter( name="c", domain=[i, j], @@ -1219,6 +1284,26 @@ def test_context_manager(data): assert math.isclose(transport.objective_value, 153.675000, rel_tol=0.001) + assert i.container.working_directory is m.working_directory + + # We should still be able to access the symbols of m + c[i, j] = 90 * d[i, j] / 100 + transport.solve() + assert math.isclose(transport.objective_value, 1536.75000, rel_tol=0.001) + + m2 = Container() + i2 = Set(m2, "i2") + a2 = Parameter(m2, "a2", domain=i2) + assert i2.container.working_directory is m2.working_directory + assert a2.container.working_directory is m2.working_directory + + with m2: + i3 = Set(m2, "i3") + assert i3.container.working_directory is m2.working_directory + + # Make sure that the symbols of m is not affected by the new context manager + assert i.container.working_directory is m.working_directory + def test_after_exception(data): m, *_ = data @@ -1440,6 +1525,26 @@ def test_multiprocessing(): assert math.isclose(expected, objective) +def test_multiprocessing_with_ctx(): + f_values = [90, 120, 150, 180] + expected_values = [153.675, 204.89999999999998, 256.125, 307.35] + with concurrent.futures.ProcessPoolExecutor() as executor: + for expected, objective in zip( + expected_values, executor.map(transport_with_ctx, f_values) + ): + assert math.isclose(expected, objective) + + +def test_threading_with_ctx(): + f_values = [90, 120, 150, 180] + expected_values = [153.675, 204.89999999999998, 256.125, 307.35] + with concurrent.futures.ThreadPoolExecutor() as executor: + for expected, objective in zip( + expected_values, executor.map(transport_with_ctx, f_values) + ): + assert math.isclose(expected, objective) + + def test_selective_loading(data): m, canning_plants, markets, capacities, demands, distances = data i = Set(m, name="i", records=canning_plants) From 5b158fe1115e10769f8eea13cb84cbf0e2edd1a0 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 13:17:55 +0300 Subject: [PATCH 092/135] remove the prints for context manager with threading test --- tests/integration/test_solve.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index c186262d..934a97db 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -12,7 +12,6 @@ import numpy as np import pytest -import gamspy as gp import gamspy._validation as validation import gamspy.math as gamspy_math from gamspy import ( @@ -176,7 +175,6 @@ def transport_with_ctx(f_value): m = Container() with m: - print(gp._ctx_managers) i = Set( name="i", records=["seattle", "san-diego"], From 11f1c1ce90eae043fe68edb6fa4134c724962ee1 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 14:26:51 +0300 Subject: [PATCH 093/135] update container docs --- CHANGELOG.md | 1 + docs/user/basics/container.rst | 30 ++++++++++++++++++++++++++++++ tests/integration/test_solve.py | 7 +++++++ 3 files changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e7bc0a..fb43f96e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ GAMSPy 1.4.1 ------------ - General - Fix implicit parameter validation bug. + - Allow the usage of Container as a context manager. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. - Documentation diff --git a/docs/user/basics/container.rst b/docs/user/basics/container.rst index 03ab1acc..16b4770b 100644 --- a/docs/user/basics/container.rst +++ b/docs/user/basics/container.rst @@ -77,6 +77,36 @@ Explicit symbols names are useful when interacting with parts of the module wher ``setRecords`` function. ``setRecords`` ensures that the GAMSPy state is synchronized with GAMS execution system. +A container can also be used as a context manager. When a container is used as a context manager, there +is no need to specify the container when creating symbols since the context manager container will automatically +be used as the container for the symbols. + +.. tabs:: + .. group-tab:: With context manager + .. code-block:: python + + import gamspy as gp + + with gp.Container() as m: + i = gp.Set() + a = gp.Alias(alias_with=i) + p = gp.Parameter() + v = gp.Variable() + e = gp.Equation() + + .. group-tab:: Without context manager + .. code-block:: python + + import gamspy as gp + + m = gp.Container() + + i = gp.Set(m) + a = gp.Alias(m, alias_with=i) + p = gp.Parameter(m) + v = gp.Variable(m) + e = gp.Equation(m) + =========================== Reading and Writing Symbols =========================== diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index 934a97db..ee5b17ec 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -1233,6 +1233,13 @@ def test_validation_3(): def test_context_manager(data): + with Container(): + i = Set() + a = Alias(alias_with=i) + _ = Parameter() + _ = Variable() + _ = Equation() + m, canning_plants, markets, capacities, demands, distances = data with m: i = Set(records=canning_plants) From bb80242f357b889bdc6744028ebd271acb53d93d Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Mon, 6 Jan 2025 12:29:31 +0100 Subject: [PATCH 094/135] minor docs update --- src/gamspy/formulations/piecewise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 148b8a0d..b7cb25f1 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -372,7 +372,7 @@ def pwl_interval_formulation( y = \\sum_{i}{(\\lambda_i * slope_i) + (b_i * offset_i) } - b_i \\in {0, 1} \\quad \\forall{i} + b_i \\in \\{0, 1\\} \\quad \\forall{i} The implementation handles discontinuities in the function. To represent a discontinuity at a specific point `x_i`, include `x_i` twice in the `x_points` From 1515979ca45e9709b8678a3981bc112ba52cbc30 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 17:35:04 +0300 Subject: [PATCH 095/135] enable autocompletion for gamspy install/uninstall solver --- src/gamspy/_cli/install.py | 3 ++- src/gamspy/_cli/uninstall.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index 6a0e1577..e43ef90e 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -143,7 +143,8 @@ def append_dist_info(files, gamspy_base_dir: str): def solver( solver_names: list[str] = typer.Argument( None, - help="solver names to be installed" + help="solver names to be installed", + autocompletion=lambda: [s.lower() for s in utils.getAvailableSolvers()] ), install_all_solvers: bool = typer.Option( False, diff --git a/src/gamspy/_cli/uninstall.py b/src/gamspy/_cli/uninstall.py index 97229c70..d80c3b69 100644 --- a/src/gamspy/_cli/uninstall.py +++ b/src/gamspy/_cli/uninstall.py @@ -32,7 +32,8 @@ def license(): def solver( solver_names: list[str] = typer.Argument( None, - help="solver names to be uninstalled" + help="solver names to be uninstalled", + autocompletion=lambda: [s.lower() for s in utils.getInstalledSolvers(utils._get_gamspy_base_directory())] ), uninstall_all_solvers: bool = typer.Option( False, From d0ca1245e609f19c074856b839ad3d416eef8a28 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 18:08:49 +0300 Subject: [PATCH 096/135] remove unused types --- src/gamspy/_cli/install.py | 2 +- src/gamspy/_cli/list.py | 2 -- src/gamspy/_cli/retrieve.py | 1 - src/gamspy/_cli/run.py | 1 - src/gamspy/_cli/uninstall.py | 2 +- 5 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index e43ef90e..cfa74b92 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -2,7 +2,7 @@ import importlib import shutil -from typing import Annotated, Iterable, Union, List +from typing import Annotated, Iterable, Union import typer from gamspy.exceptions import GamspyException, ValidationError diff --git a/src/gamspy/_cli/list.py b/src/gamspy/_cli/list.py index 842c0be7..2bf3da1f 100644 --- a/src/gamspy/_cli/list.py +++ b/src/gamspy/_cli/list.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Annotated, Union - import typer from rich import print from rich.console import Console diff --git a/src/gamspy/_cli/retrieve.py b/src/gamspy/_cli/retrieve.py index 6ce14fb9..33fbd067 100644 --- a/src/gamspy/_cli/retrieve.py +++ b/src/gamspy/_cli/retrieve.py @@ -2,7 +2,6 @@ import os import subprocess -from typing import Annotated, Union import typer diff --git a/src/gamspy/_cli/run.py b/src/gamspy/_cli/run.py index 96ef6800..ba294f23 100644 --- a/src/gamspy/_cli/run.py +++ b/src/gamspy/_cli/run.py @@ -3,7 +3,6 @@ import platform import subprocess import sys -from typing import Annotated, Union from enum import Enum import typer diff --git a/src/gamspy/_cli/uninstall.py b/src/gamspy/_cli/uninstall.py index d80c3b69..f712c4db 100644 --- a/src/gamspy/_cli/uninstall.py +++ b/src/gamspy/_cli/uninstall.py @@ -1,7 +1,7 @@ from __future__ import annotations import os import subprocess -from typing import Annotated, Iterable, Union, List +from typing import Iterable from gamspy.exceptions import GamspyException, ValidationError import gamspy.utils as utils from .util import remove_solver_entry From 25aa64ff55f473298cde0bd445417db08cdbab23 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Mon, 6 Jan 2025 18:17:40 +0300 Subject: [PATCH 097/135] rename solver_names to solver for better helping message --- src/gamspy/_cli/install.py | 12 ++++++------ src/gamspy/_cli/uninstall.py | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index cfa74b92..65be9472 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -141,7 +141,7 @@ def append_dist_info(files, gamspy_base_dir: str): help="[bold][yellow]Examples[/yellow][/bold]: gamspy install solver " ) def solver( - solver_names: list[str] = typer.Argument( + solver: list[str] = typer.Argument( None, help="solver names to be installed", autocompletion=lambda: [s.lower() for s in utils.getAvailableSolvers()] @@ -252,9 +252,9 @@ def install_addons(addons: Iterable[str]): available_solvers = utils.getAvailableSolvers() installed_solvers = utils.getInstalledSolvers(gamspy_base.directory) diff = [] - for solver in available_solvers: - if solver not in installed_solvers: - diff.append(solver) + for available_solver in available_solvers: + if available_solver not in installed_solvers: + diff.append(available_solver) install_addons(diff) return @@ -268,12 +268,12 @@ def install_addons(addons: Iterable[str]): except FileNotFoundError as e: raise ValidationError("No existing add-on solvers found!") from e - if solver_names is None: + if solver is None: raise ValidationError( "Solver name is missing: `gamspy install solver `" ) - install_addons(solver_names) + install_addons(solver) if __name__ == "__main__": app() diff --git a/src/gamspy/_cli/uninstall.py b/src/gamspy/_cli/uninstall.py index f712c4db..75e9c898 100644 --- a/src/gamspy/_cli/uninstall.py +++ b/src/gamspy/_cli/uninstall.py @@ -30,7 +30,7 @@ def license(): short_help="To uninstall solvers" ) def solver( - solver_names: list[str] = typer.Argument( + solver: list[str] = typer.Argument( None, help="solver names to be uninstalled", autocompletion=lambda: [s.lower() for s in utils.getInstalledSolvers(utils._get_gamspy_base_directory())] @@ -111,12 +111,12 @@ def remove_addons(addons: Iterable[str]): # All add-on solvers are gone. return - if solver_names is None: + if solver is None: raise ValidationError( "Solver name is missing: `gamspy uninstall solver `" ) - remove_addons(solver_names) + remove_addons(solver) if __name__ == "__main__": app() From a0d7d60d7bc32004f2f0bf293097afac7da5606e Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Mon, 6 Jan 2025 16:30:51 +0100 Subject: [PATCH 098/135] add formulations page in docs --- docs/user/advanced/advanced.rst | 1 + docs/user/advanced/formulations.rst | 160 ++++++++++++++++++++++++++++ docs/user/images/pwl.png | Bin 0 -> 43257 bytes docs/user/images/pwl_excluded.png | Bin 0 -> 41725 bytes docs/user/images/pwl_unbounded.png | Bin 0 -> 46868 bytes 5 files changed, 161 insertions(+) create mode 100644 docs/user/advanced/formulations.rst create mode 100644 docs/user/images/pwl.png create mode 100644 docs/user/images/pwl_excluded.png create mode 100644 docs/user/images/pwl_unbounded.png diff --git a/docs/user/advanced/advanced.rst b/docs/user/advanced/advanced.rst index 862ec842..13b414d4 100644 --- a/docs/user/advanced/advanced.rst +++ b/docs/user/advanced/advanced.rst @@ -19,3 +19,4 @@ fundamental GAMSPy ideas and philosophy. ./model_instance ./external_equations ./extrinsic_functions + ./formulations diff --git a/docs/user/advanced/formulations.rst b/docs/user/advanced/formulations.rst new file mode 100644 index 00000000..44c863a7 --- /dev/null +++ b/docs/user/advanced/formulations.rst @@ -0,0 +1,160 @@ +.. _formulations: + +************ +Formulations +************ + +.. meta:: + :description: GAMSPy User Guide + :keywords: User, Guide, GAMSPy, gamspy, GAMS, gams, mathematical modeling, sparsity, performance, piecewise, linear, function + +Formulations in GAMSPy provide an intuitive and user-friendly way to define +complex relations without delving deeply into the underlying mathematical +details. You can focus on what you want to achieve rather than how it is +implemented. For those interested in exploring the mechanics, GAMSPy also +provides full visibility into the underlying formulation. We've implemented a +variety of versatile formulations, including piecewise linear functions and +neural network construction blocks, empowering you to seamlessly integrate +advanced concepts into your optimization workflows. + + +Neural Network Related Formulations +----------------------------------- + +GAMSPy supports following neural network related formulations: + +- :meth:`Linear ` +- :meth:`Conv2d ` +- :meth:`MaxPool2d ` +- :meth:`MinPool2d ` +- :meth:`AvgPool2d ` +- :meth:`flatten_dims ` + +You can find more info at :ref:`nn-formulations`. + + +Piecewise Linear Functions +-------------------------- + +Piecewise linear functions are a cornerstone of practical optimization, as they +naturally arise in countless real-world scenarios. Whether modeling cost +structures, approximating nonlinear relationships, or defining breakpoints in +decision processes, their versatility and prevalence make them indispensable. +Recognizing this, we implemented robust support for piecewise linear +formulations in GAMSPy, enabling users to seamlessly incorporate these +essential tools into their models. + +We currently support following two formulations for implementing piecewise +linear functions: + +- :meth:`pwl_interval_formulation ` +- :meth:`pwl_convexity_formulation ` + + +To define a piecewise linear function, you need to specify x and y coordinates +of the breakpoints. Let's imagine we want to model the following function: + +.. image:: ../images/pwl.png + :width: 300 + :align: center + +With using either formulation, we can do as following: + + +.. tabs:: + .. tab:: Interval formulation + .. code-block:: python + + import gamspy as gp + + m = gp.Container() + x = gp.Variable(m) + y, eqs = gp.formulations.pwl_interval_formulation( + x, + [0, 1, 3, 3, 4], + [2, 1, 1, 2, 3], + ) + + .. tab:: Convexity formulation + .. code-block:: python + + import gamspy as gp + + m = gp.Container() + x = gp.Variable(m) + y, eqs = gp.formulations.pwl_convexity_formulation( + x, + [0, 1, 3, 3, 4], + [2, 1, 1, 2, 3], + ) + +**Discontinuities** + +In the x points (the first array), point 3 is repeated twice. It is because +when you have discontinuities in your piecewise linear function you can +represent them by repeating the x coordinate with a new y value. + + +**Variable Bounds** + +By default, x is limited to be in the range you defined, in this case betwen 0 +and 4. If you want x to be not limited in the range you defined, you can set +`bound_domain` to `False`. When `bound_domain` is set to `False`, it is assumed +that the first and the last line segments are extended. However, to accomplish +that new `SOS1` and `binary` type variables are introduced. + +.. image:: ../images/pwl_unbounded.png + :width: 300 + :align: center + + +.. code-block:: python + + import gamspy as gp + + m = gp.Container() + x = gp.Variable(m) + y, eqs = gp.formulations.pwl_interval_formulation( + x, + [0, 1, 3, 3, 4], + [2, 1, 1, 2, 3], + bound_domain=False, + ) + + +**Excluded Ranges** + +You can exclude certain ranges in your piecewise linear function to ensure +x value never gets a value within that range. Let's say we want to ensure +x does not get a value between 1.5 and 2. You can do it by inserting `None` +between x values that you like to exclude. + + +.. image:: ../images/pwl_excluded.png + :width: 300 + :align: center + + +.. code-block:: python + + import gamspy as gp + + m = gp.Container() + x = gp.Variable(m) + y, eqs = gp.formulations.pwl_interval_formulation( + x, + [0, 1, 1.5, None, 2, 3, 3, 4], + [2, 1, 1, None, 1, 1, 2, 3], + bound_domain=False, + ) + + +**Comparison between the interval and the convexity formulation** + +For detailed implementation insights, refer to :meth:`pwl_interval_formulation +` and :meth:`pwl_convexity_formulation +`. Our empirical analysis +suggests that the interval formulation often results in faster solve times. +However, since the formulations are designed to be easily interchangeable, we +encourage you to experiment with both to determine which works best for your +use case. diff --git a/docs/user/images/pwl.png b/docs/user/images/pwl.png new file mode 100644 index 0000000000000000000000000000000000000000..ab632de00044b0cc101640b038a2615377cc16c5 GIT binary patch literal 43257 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 aCgH4zDB@@G_P_!EB=1Y#%eeFW<^KQ=Cj4Ij literal 0 HcmV?d00001 diff --git a/docs/user/images/pwl_excluded.png b/docs/user/images/pwl_excluded.png new file mode 100644 index 0000000000000000000000000000000000000000..e16d63b57cef6260e95e315c64c091404b890d9b GIT binary patch literal 41725 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 P@Fydsa699czUTh{1H^HB literal 0 HcmV?d00001 diff --git a/docs/user/images/pwl_unbounded.png b/docs/user/images/pwl_unbounded.png new file mode 100644 index 0000000000000000000000000000000000000000..4a5c36bdb14ea54a98562f012b79a461743437e9 GIT binary patch literal 46868 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 Date: Mon, 6 Jan 2025 18:27:53 +0100 Subject: [PATCH 099/135] split bound_domain to bound_left and bound_right in pwl --- docs/user/advanced/formulations.rst | 8 +- src/gamspy/formulations/piecewise.py | 111 +++++++++++--------- tests/integration/models/piecewiseLinear.py | 44 ++++++-- tests/unit/test_formulation.py | 26 ++++- 4 files changed, 124 insertions(+), 65 deletions(-) diff --git a/docs/user/advanced/formulations.rst b/docs/user/advanced/formulations.rst index 44c863a7..cc2bd5e5 100644 --- a/docs/user/advanced/formulations.rst +++ b/docs/user/advanced/formulations.rst @@ -99,8 +99,8 @@ represent them by repeating the x coordinate with a new y value. By default, x is limited to be in the range you defined, in this case betwen 0 and 4. If you want x to be not limited in the range you defined, you can set -`bound_domain` to `False`. When `bound_domain` is set to `False`, it is assumed -that the first and the last line segments are extended. However, to accomplish +`bound_left` and/or `bound_right` to `False`. When either is set to `False`, it is assumed +that the corresponding line segments are extended. However, to accomplish that new `SOS1` and `binary` type variables are introduced. .. image:: ../images/pwl_unbounded.png @@ -118,7 +118,8 @@ that new `SOS1` and `binary` type variables are introduced. x, [0, 1, 3, 3, 4], [2, 1, 1, 2, 3], - bound_domain=False, + bound_left=False, + bound_right=False, ) @@ -145,7 +146,6 @@ between x values that you like to exclude. x, [0, 1, 1.5, None, 2, 3, 3, 4], [2, 1, 1, None, 1, 1, 2, 3], - bound_domain=False, ) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index b7cb25f1..b3c31761 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -351,7 +351,8 @@ def pwl_interval_formulation( input_x: gp.Variable, x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], - bound_domain: bool = True, + bound_left: bool = True, + bound_right: bool = True, ) -> tuple[gp.Variable, list[gp.Equation]]: """ This function implements a piecewise linear function using the intervals formulation. @@ -392,9 +393,9 @@ def pwl_interval_formulation( ranges always introduce additional binary variables. The input variable `input_x` is restricted to the range defined by - `x_points` unless `bound_domain` is set to False. Setting `bound_domain` to True, - creates SOS1 type of variables. When `input_x` is not bound, you can assume as - if the first and the last line segments are extended. + `x_points` unless `bound_left` or `bound_right` is set to False. Setting + either to True, creates SOS1 type of variables. When `input_x` is not bound, + you can assume as if the first and/or the last line segments are extended. Returns the dependent variable `y` and the equations required to model the piecewise linear relationship. @@ -407,8 +408,10 @@ def pwl_interval_formulation( Break points of the piecewise linear function in the x-axis y_points: typing.Sequence[int | float] Break points of the piecewise linear function in the y-axis - bound_domain: bool = True - If input_x should be limited to interval defined by min(x_points), max(x_points) + bound_left: bool = True + If input_x should be limited to start from x_points[0] + bound_right: bool = True + If input_x should be limited to end at x_points[-1] Returns ------- @@ -427,8 +430,11 @@ def pwl_interval_formulation( if not isinstance(input_x, gp.Variable): raise ValidationError("input_x is expected to be a Variable") - if not isinstance(bound_domain, bool): - raise ValidationError("bound_domain is expected to be a boolean") + if not isinstance(bound_left, bool): + raise ValidationError("bound_left is expected to be a boolean") + + if not isinstance(bound_right, bool): + raise ValidationError("bound_right is expected to be a boolean") x_points, y_points, discontinuous_indices, none_indices = _check_points( x_points, y_points @@ -469,32 +475,29 @@ def pwl_interval_formulation( x_term = 0 y_term = 0 pick_one_term = 0 - if bound_domain: + + if bound_left is False or bound_right is False: + m_neg, m_pos = _get_end_slopes(x_points, y_points) + + if bound_left: out_y.lo[...] = min(y_points) - out_y.up[...] = max(y_points) else: x_neg_inf, b_neg_inf, eqs_neg_inf = _generate_ray(m, input_domain) equations.extend(eqs_neg_inf) + x_term += -x_neg_inf + (b_neg_inf * x_points[0]) + y_term += -(m_neg * x_neg_inf) + (b_neg_inf * y_points[0]) + pick_one_term += b_neg_inf + + if bound_right: + out_y.up[...] = max(y_points) + else: x_pos_inf, b_pos_inf, eqs_pos_inf = _generate_ray(m, input_domain) equations.extend(eqs_pos_inf) - pick_one_term = b_neg_inf + b_pos_inf - - m_neg, m_pos = _get_end_slopes(x_points, y_points) - - x_term = ( - x_pos_inf - - x_neg_inf - + (b_neg_inf * x_points[0]) - + (b_pos_inf * x_points[-1]) - ) - y_term = ( - (m_pos * x_pos_inf) - - (m_neg * x_neg_inf) - + (b_neg_inf * y_points[0]) - + (b_pos_inf * y_points[-1]) - ) + x_term += x_pos_inf + (b_pos_inf * x_points[-1]) + y_term += (m_pos * x_pos_inf) + (b_pos_inf * y_points[-1]) + pick_one_term += b_pos_inf pick_one = m.addEquation(domain=input_domain) pick_one[...] = gp.Sum(J, bin_var) + pick_one_term == 1 @@ -521,7 +524,8 @@ def pwl_convexity_formulation( x_points: typing.Sequence[int | float], y_points: typing.Sequence[int | float], using: typing.Literal["binary", "sos2"] = "binary", - bound_domain: bool = True, + bound_left: bool = True, + bound_right: bool = True, ) -> tuple[gp.Variable, list[gp.Equation]]: """ This function implements a piecewise linear function using the convexity formulation. @@ -567,9 +571,9 @@ def pwl_convexity_formulation( of the using argument. The input variable `input_x` is restricted to the range defined by - `x_points` unless `bound_domain` is set to False. Setting `bound_domain` to True, - creates SOS1 type of variables independent from the `using` parameter. When `input_x` is - not bound, you can assume as if the first and the last line segments are extended. + `x_points` unless `bound_left` or `bound_right` is set to False. Setting + either to True, creates SOS1 type of variables. When `input_x` is not bound, + you can assume as if the first and/or the last line segments are extended. Returns the dependent variable `y` and the equations required to model the piecewise linear relationship. @@ -584,8 +588,10 @@ def pwl_convexity_formulation( Break points of the piecewise linear function in the y-axis using: str = "binary" What type of variable is used during implementing piecewise function - bound_domain: bool = True - If input_x should be limited to interval defined by min(x_points), max(x_points) + bound_left: bool = True + If input_x should be limited to start from x_points[0] + bound_right: bool = True + If input_x should be limited to end at x_points[-1] Returns ------- @@ -609,8 +615,11 @@ def pwl_convexity_formulation( if not isinstance(input_x, gp.Variable): raise ValidationError("input_x is expected to be a Variable") - if not isinstance(bound_domain, bool): - raise ValidationError("bound_domain is expected to be a boolean") + if not isinstance(bound_left, bool): + raise ValidationError("bound_left is expected to be a boolean") + + if not isinstance(bound_right, bool): + raise ValidationError("bound_right is expected to be a boolean") x_points, y_points, discontinuous_indices, none_indices = _check_points( x_points, y_points @@ -632,37 +641,41 @@ def pwl_convexity_formulation( domain=[*input_domain, J], type="free" if using == "binary" else "sos2" ) - x_term = 0 - y_term = 0 lambda_var.lo[...] = 0 lambda_var.up[...] = 1 - if bound_domain: + + x_term = 0 + y_term = 0 + + if bound_left is False or bound_right is False: + m_neg, m_pos = _get_end_slopes(x_points, y_points) + + if bound_left: out_y.lo[...] = min(y_points) - out_y.up[...] = max(y_points) else: x_neg_inf, b_neg_inf, eqs_neg_inf = _generate_ray(m, input_domain) equations.extend(eqs_neg_inf) - x_pos_inf, b_pos_inf, eqs_pos_inf = _generate_ray(m, input_domain) - equations.extend(eqs_pos_inf) - - pick_side = m.addEquation(domain=b_neg_inf.domain) - pick_side[...] = b_neg_inf + b_pos_inf <= 1 - equations.append(pick_side) - limit_b_neg_inf = m.addEquation(domain=b_neg_inf.domain) limit_b_neg_inf[...] = b_neg_inf <= lambda_var[[*input_domain, "0"]] equations.append(limit_b_neg_inf) + x_term += -x_neg_inf + y_term += -(m_neg * x_neg_inf) + + if bound_right: + out_y.up[...] = max(y_points) + else: + x_pos_inf, b_pos_inf, eqs_pos_inf = _generate_ray(m, input_domain) + equations.extend(eqs_pos_inf) + limit_b_pos_inf = m.addEquation(domain=b_pos_inf.domain) last = str(len(J) - 1) limit_b_pos_inf[...] = b_pos_inf <= lambda_var[[*input_domain, last]] equations.append(limit_b_pos_inf) - m_neg, m_pos = _get_end_slopes(x_points, y_points) - - x_term = x_pos_inf - x_neg_inf - y_term = (m_pos * x_pos_inf) - (m_neg * x_neg_inf) + x_term += x_pos_inf + y_term += m_pos * x_pos_inf lambda_sum = m.addEquation(domain=input_x.domain) lambda_sum[...] = gp.Sum(J, lambda_var) == 1 diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index 424c43dd..7fafb6ad 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -112,7 +112,12 @@ def pwl_suite(fct, name): x_points = [-4, -2, 1, 3] y_points = [-2, 0, 0, 2] y, eqs = fct( - x, x_points, y_points, using=using, bound_domain=False + x, + x_points, + y_points, + using=using, + bound_left=False, + bound_right=False, ) x.fx[...] = -5 model = gp.Model( @@ -130,7 +135,12 @@ def pwl_suite(fct, name): x_points = [-4, -2, 1, 3] y_points = [-2, 0, 0, 0] y, eqs = fct( - x, x_points, y_points, using=using, bound_domain=False + x, + x_points, + y_points, + using=using, + bound_left=False, + bound_right=False, ) model = gp.Model( m, equations=eqs, objective=y, sense="max", problem="mip" @@ -146,7 +156,12 @@ def pwl_suite(fct, name): x_points = [-4, -2, 1, 3] y_points = [-5, -5, 0, 2] y, eqs = fct( - x, x_points, y_points, using=using, bound_domain=False + x, + x_points, + y_points, + using=using, + bound_left=False, + bound_right=False, ) x.lo[...] = "-inf" x.up[...] = "inf" @@ -163,7 +178,9 @@ def pwl_suite(fct, name): else: x_points = [-4, -2, 1, 3] y_points = [-2, 0, 0, 2] - y, eqs = fct(x, x_points, y_points, bound_domain=False) + y, eqs = fct( + x, x_points, y_points, bound_left=False, bound_right=False + ) x.fx[...] = -5 model = gp.Model( m, equations=eqs, objective=y, sense="min", problem="mip" @@ -179,7 +196,9 @@ def pwl_suite(fct, name): # y is upper bounded x_points = [-4, -2, 1, 3] y_points = [-2, 0, 0, 0] - y, eqs = fct(x, x_points, y_points, bound_domain=False) + y, eqs = fct( + x, x_points, y_points, bound_left=False, bound_right=False + ) model = gp.Model( m, equations=eqs, objective=y, sense="max", problem="mip" ) @@ -193,7 +212,9 @@ def pwl_suite(fct, name): # y is lower bounded x_points = [-4, -2, 1, 3] y_points = [-5, -5, 0, 2] - y, eqs = fct(x, x_points, y_points, bound_domain=False) + y, eqs = fct( + x, x_points, y_points, bound_left=False, bound_right=False + ) x.lo[...] = "-inf" x.up[...] = "inf" model = gp.Model( @@ -286,7 +307,12 @@ def pwl_suite(fct, name): x_points = [-4, -4, -2, 1, 3, 3] y_points = [20, -2, 0, 0, 2, 9] y, eqs = fct( - x, x_points, y_points, using=using, bound_domain=False + x, + x_points, + y_points, + using=using, + bound_left=False, + bound_right=False, ) x.fx[...] = -5 model = gp.Model( @@ -299,7 +325,9 @@ def pwl_suite(fct, name): else: x_points = [-4, -4, -2, 1, 3, 3] y_points = [20, -2, 0, 0, 2, 9] - y, eqs = fct(x, x_points, y_points, bound_domain=False) + y, eqs = fct( + x, x_points, y_points, bound_left=False, bound_right=False + ) x.fx[...] = -5 model = gp.Model( m, equations=eqs, objective=y, sense="min", problem="mip" diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 6855aaed..643c065d 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -184,7 +184,8 @@ def test_pwl_with_sos2(data): x, x_points, y_points, - bound_domain=False, + bound_left=False, + bound_right=False, using="sos2", ) @@ -238,10 +239,18 @@ def test_pwl_finished_start_with_disc(data): x_points = [1, 1, None, 2, 3, 3] y_points = [0, 10, None, 20, 45, 0] y, eqs = pwl_convexity_formulation( - x, x_points, y_points, bound_domain=False + x, + x_points, + y_points, + bound_left=False, + bound_right=False, ) y2, eqs2 = pwl_interval_formulation( - x, x_points, y_points, bound_domain=False + x, + x_points, + y_points, + bound_left=False, + bound_right=False, ) @@ -413,5 +422,14 @@ def test_pwl_validation(data, fct): x, [2, None, 4, 10], [10, None, 20, 40], - bound_domain="yes", + bound_left="yes", + ) + + pytest.raises( + ValidationError, + fct, + x, + [2, None, 4, 10], + [10, None, 20, 40], + bound_right="yes", ) From 6e731ebe92356c2641652479e75ad3da3b2a9a23 Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Mon, 6 Jan 2025 18:42:16 +0100 Subject: [PATCH 100/135] add more tests --- tests/integration/models/piecewiseLinear.py | 45 +++++++++++++++++++++ tests/unit/test_formulation.py | 12 ++++++ 2 files changed, 57 insertions(+) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index 7fafb6ad..5a9b9141 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -336,6 +336,51 @@ def pwl_suite(fct, name): assert y.toDense() == 20, "Case 15 failed !" print("Case 15 passed !") + # test single bound cases + x_points = [-4, -2, 1, 3] + y_points = [-2, 0, 0, 2] + y, eqs = fct( + x, + x_points, + y_points, + bound_left=False, + bound_right=True, + ) + x.fx[...] = -5 + model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") + model.solve() + + assert y.toDense() == -3, "Case 16 failed !" + print("Case 16 passed !") + + x.fx[...] = 5 # bounded from right + res = model.solve() + assert ( + res["Model Status"].item() == "IntegerInfeasible" + ), "Case 17 failed !" + print("Case 17 passed !") + + y, eqs = fct( + x, + x_points, + y_points, + bound_left=True, + bound_right=False, + ) + x.fx[...] = -5 + model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") + res = model.solve() + assert ( + res["Model Status"].item() == "IntegerInfeasible" + ), "Case 18 failed !" + print("Case 18 passed !") + + x.fx[...] = 5 + model.solve() + + assert y.toDense() == 4, "Case 19 failed !" + print("Case 19 passed !") + m.close() diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 643c065d..3621aedf 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -254,6 +254,18 @@ def test_pwl_finished_start_with_disc(data): ) +@pytest.mark.parametrize("fct", fcts_to_test) +def test_pwl_bound_cases(data, fct): + x = data["x"] + x_points = data["x_points"] + y_points = data["y_points"] + + fct(x, x_points, y_points, bound_left=False, bound_right=False) + fct(x, x_points, y_points, bound_left=False, bound_right=True) + fct(x, x_points, y_points, bound_left=True, bound_right=True) + fct(x, x_points, y_points, bound_left=True, bound_right=False) + + @pytest.mark.parametrize("fct", fcts_to_test) def test_pwl_validation(data, fct): x = data["x"] From dc9505fafb3b09973404280373a6fcccb8536d9c Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Mon, 6 Jan 2025 18:54:30 +0100 Subject: [PATCH 101/135] update changelogs --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e7bc0a..137c014f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,13 @@ GAMSPy 1.4.1 ------------ - General - Fix implicit parameter validation bug. + - Add piecewise linear function formulations. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. + - Add tests for piecewise linear functions. - Documentation - Install dependencies in the first cell of the example transportation notebook. + - Add Formulations page to list piecewise linear functions and nn formulations. GAMSPy 1.4.0 ------------ From 829c1a7d3a0ec5f7f49b7278c27d157b8ea36efe Mon Sep 17 00:00:00 2001 From: msoyturk Date: Tue, 7 Jan 2025 11:26:34 +0300 Subject: [PATCH 102/135] close after finish --- tests/integration/test_solve.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index ee5b17ec..df993a75 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -12,6 +12,7 @@ import numpy as np import pytest +import gamspy as gp import gamspy._validation as validation import gamspy.math as gamspy_math from gamspy import ( @@ -156,7 +157,6 @@ def transport(f_value): transport.solve() m.close() - return transport.objective_value @@ -243,9 +243,8 @@ def transport_with_ctx(f_value): ) transport.solve() - m.close() - - return transport.objective_value + m.close() + return transport.objective_value def transport2(f_value): @@ -1539,6 +1538,8 @@ def test_multiprocessing_with_ctx(): ): assert math.isclose(expected, objective) + assert gp._ctx_managers == {} + def test_threading_with_ctx(): f_values = [90, 120, 150, 180] @@ -1549,6 +1550,8 @@ def test_threading_with_ctx(): ): assert math.isclose(expected, objective) + assert gp._ctx_managers == {} + def test_selective_loading(data): m, canning_plants, markets, capacities, demands, distances = data From df59a654888b1155d962831082143e395296082e Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Tue, 7 Jan 2025 11:06:22 +0000 Subject: [PATCH 103/135] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Muhammet Soyturk --- src/gamspy/formulations/piecewise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index b3c31761..413ff600 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -241,7 +241,7 @@ def _indicator( "indicator_var and expr must have the same domain" ) - if expr.data in "=e=": + if expr.data == "=e=": # sos1(bin_var, lhs - rhs) might be better eqs1 = _indicator( indicator_var, indicator_val, expr.left <= expr.right From 0c79aa1ccfd6703ecd750158f3c611e3ccaa63cd Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Tue, 7 Jan 2025 15:40:23 +0000 Subject: [PATCH 104/135] Apply 35 suggestion(s) to 2 file(s) Co-authored-by: Muhammet Soyturk --- docs/user/advanced/formulations.rst | 10 +-- tests/integration/models/piecewiseLinear.py | 72 ++++++++++----------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/docs/user/advanced/formulations.rst b/docs/user/advanced/formulations.rst index cc2bd5e5..5876e82a 100644 --- a/docs/user/advanced/formulations.rst +++ b/docs/user/advanced/formulations.rst @@ -71,8 +71,8 @@ With using either formulation, we can do as following: x = gp.Variable(m) y, eqs = gp.formulations.pwl_interval_formulation( x, - [0, 1, 3, 3, 4], - [2, 1, 1, 2, 3], + x_points=[0, 1, 3, 3, 4], + y_points=[2, 1, 1, 2, 3], ) .. tab:: Convexity formulation @@ -84,13 +84,13 @@ With using either formulation, we can do as following: x = gp.Variable(m) y, eqs = gp.formulations.pwl_convexity_formulation( x, - [0, 1, 3, 3, 4], - [2, 1, 1, 2, 3], + x_points=[0, 1, 3, 3, 4], + x_points=[2, 1, 1, 2, 3], ) **Discontinuities** -In the x points (the first array), point 3 is repeated twice. It is because +In the `x_points`, point 3 is repeated twice. It is because when you have discontinuities in your piecewise linear function you can represent them by repeating the x coordinate with a new y value. diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index 5a9b9141..203cb8bf 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -119,14 +119,14 @@ def pwl_suite(fct, name): bound_left=False, bound_right=False, ) - x.fx[...] = -5 + x.fx = -5 model = gp.Model( m, equations=eqs, objective=y, sense="min", problem="mip" ) model.solve() assert math.isclose(y.toDense(), -3), "Case 5 failed !" print("Case 5 passed !") - x.fx[...] = 100 + x.fx = 100 model.solve() assert math.isclose(y.toDense(), 99), "Case 6 failed !" print("Case 6 passed !") @@ -148,7 +148,7 @@ def pwl_suite(fct, name): model.solve() assert math.isclose(y.toDense(), 0), "Case 7 failed !" print("Case 7 passed !") - x.fx[...] = 100 + x.fx = 100 model.solve() assert math.isclose(y.toDense(), 0), "Case 8 failed !" print("Case 8 passed !") @@ -163,15 +163,15 @@ def pwl_suite(fct, name): bound_left=False, bound_right=False, ) - x.lo[...] = "-inf" - x.up[...] = "inf" + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF model = gp.Model( m, equations=eqs, objective=y, sense="min", problem="mip" ) model.solve() assert math.isclose(y.toDense(), -5), "Case 9 failed !" print("Case 9 passed !") - x.fx[...] = -100 + x.fx = -100 model.solve() assert math.isclose(y.toDense(), -5), "Case 10 failed !" print("Case 10 passed !") @@ -181,14 +181,14 @@ def pwl_suite(fct, name): y, eqs = fct( x, x_points, y_points, bound_left=False, bound_right=False ) - x.fx[...] = -5 + x.fx = -5 model = gp.Model( m, equations=eqs, objective=y, sense="min", problem="mip" ) model.solve() assert math.isclose(y.toDense(), -3), "Case 5 failed !" print("Case 5 passed !") - x.fx[...] = 100 + x.fx = 100 model.solve() assert math.isclose(y.toDense(), 99), "Case 6 failed !" print("Case 6 passed !") @@ -205,7 +205,7 @@ def pwl_suite(fct, name): model.solve() assert math.isclose(y.toDense(), 0), "Case 7 failed !" print("Case 7 passed !") - x.fx[...] = 100 + x.fx = 100 model.solve() assert math.isclose(y.toDense(), 0), "Case 8 failed !" print("Case 8 passed !") @@ -215,15 +215,15 @@ def pwl_suite(fct, name): y, eqs = fct( x, x_points, y_points, bound_left=False, bound_right=False ) - x.lo[...] = "-inf" - x.up[...] = "inf" + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF model = gp.Model( m, equations=eqs, objective=y, sense="min", problem="mip" ) model.solve() assert math.isclose(y.toDense(), -5), "Case 9 failed !" print("Case 9 passed !") - x.fx[...] = -100 + x.fx = -100 model.solve() assert math.isclose(y.toDense(), -5), "Case 10 failed !" print("Case 10 passed !") @@ -232,8 +232,8 @@ def pwl_suite(fct, name): x_points = [1, 4, 4, 10] y_points = [1, 4, 8, 25] y, eqs = fct(x, x_points, y_points) - x.fx[...] = 4 - y.fx[...] = 6 # y can be either 4 or 8 but not their convex combination + x.fx = 4 + y.fx = 6 # y can be either 4 or 8 but not their convex combination model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") res = model.solve() assert ( @@ -245,7 +245,7 @@ def pwl_suite(fct, name): x_points = [1, 4, None, 6, 10] y_points = [1, 4, None, 8, 25] y, eqs = fct(x, x_points, y_points) - x.fx[...] = 5 # should be IntegerInfeasible since 5 \in [4, 6] + x.fx = 5 # should be IntegerInfeasible since 5 \in [4, 6] model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") res = model.solve() assert ( @@ -257,8 +257,8 @@ def pwl_suite(fct, name): x_points = [1, 4, None, 6, 10] y_points = [1, 4, None, 30, 25] y, eqs = fct(x, x_points, y_points) - x.lo[...] = "-inf" - x.up[...] = "inf" + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") res = model.solve() assert x.toDense() == 6, "Case 13 failed !" @@ -269,8 +269,8 @@ def pwl_suite(fct, name): x_points = [1, 4, None, 6, 10] y_points = [1, 45, None, 30, 25] y, eqs = fct(x, x_points, y_points) - x.lo[...] = "-inf" - x.up[...] = "inf" + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF model = gp.Model(m, equations=eqs, objective=y, sense="max", problem="mip") res = model.solve() assert x.toDense() == 4, "Case 14 failed !" @@ -314,7 +314,7 @@ def pwl_suite(fct, name): bound_left=False, bound_right=False, ) - x.fx[...] = -5 + x.fx = -5 model = gp.Model( m, equations=eqs, objective=y, sense="min", problem="mip" ) @@ -328,7 +328,7 @@ def pwl_suite(fct, name): y, eqs = fct( x, x_points, y_points, bound_left=False, bound_right=False ) - x.fx[...] = -5 + x.fx = -5 model = gp.Model( m, equations=eqs, objective=y, sense="min", problem="mip" ) @@ -346,14 +346,14 @@ def pwl_suite(fct, name): bound_left=False, bound_right=True, ) - x.fx[...] = -5 + x.fx = -5 model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") model.solve() assert y.toDense() == -3, "Case 16 failed !" print("Case 16 passed !") - x.fx[...] = 5 # bounded from right + x.fx = 5 # bounded from right res = model.solve() assert ( res["Model Status"].item() == "IntegerInfeasible" @@ -367,7 +367,7 @@ def pwl_suite(fct, name): bound_left=True, bound_right=False, ) - x.fx[...] = -5 + x.fx = -5 model = gp.Model(m, equations=eqs, objective=y, sense="min", problem="mip") res = model.solve() assert ( @@ -375,7 +375,7 @@ def pwl_suite(fct, name): ), "Case 18 failed !" print("Case 18 passed !") - x.fx[...] = 5 + x.fx = 5 model.solve() assert y.toDense() == 4, "Case 19 failed !" @@ -398,14 +398,14 @@ def indicator_suite(): sense="max", problem="mip", ) - b.fx[...] = 1 + b.fx = 1 model.solve() assert x.toDense() == 50, "Case 1 failed !" print("Case 1 passed !") eqs2 = piecewise._indicator(b, 0, x <= 500) - b.lo[...] = 0 - b.up[...] = 1 + b.lo = 0 + b.up = 1 model = gp.Model( m, @@ -429,8 +429,8 @@ def indicator_suite(): problem="mip", ) # b = 0 does not mean x cannot be less than 50 - b.fx[...] = 0 - x.fx[...] = 20 + b.fx = 0 + x.fx = 20 model.solve() assert x.toDense() == 20, "Case 3 failed !" print("Case 3 passed !") @@ -443,9 +443,9 @@ def indicator_suite(): sense="min", problem="mip", ) - b.fx[...] = 1 - x.lo[...] = "-inf" - x.up[...] = "inf" + b.fx = 1 + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF model.solve() assert x.toDense() == 120, "Case 4 failed !" print("Case 4 passed !") @@ -458,9 +458,9 @@ def indicator_suite(): sense="min", problem="mip", ) - b.fx[...] = 1 - x.lo[...] = "-inf" - x.up[...] = "inf" + b.fx = 1 + x.lo = gp.SpecialValues.NEGINF + x.up = gp.SpecialValues.POSINF model.solve() assert x.toDense() == 99, "Case 5 failed !" print("Case 5 passed !") From 1c6f4aab847495dabf0d09cf0828a39a2b10ebcf Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Tue, 7 Jan 2025 16:53:18 +0100 Subject: [PATCH 105/135] iterate intervals once and add scalar test case for _indicator --- src/gamspy/formulations/piecewise.py | 13 +++++++++---- tests/unit/test_formulation.py | 6 ++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 413ff600..5bd8caf1 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -443,10 +443,15 @@ def pwl_interval_formulation( equations = [] intervals = points_to_intervals(x_points, y_points, combined_indices) - lowerbounds_input = [(str(i), k[0]) for i, k in enumerate(intervals)] - upperbounds_input = [(str(i), k[1]) for i, k in enumerate(intervals)] - slopes_input = [(str(i), k[2]) for i, k in enumerate(intervals)] - offsets_input = [(str(i), k[3]) for i, k in enumerate(intervals)] + lowerbounds_input = [] + upperbounds_input = [] + slopes_input = [] + offsets_input = [] + for i, (lb, ub, slope, offset) in enumerate(intervals): + lowerbounds_input.append((str(i), lb)) + upperbounds_input.append((str(i), ub)) + slopes_input.append((str(i), slope)) + offsets_input.append((str(i), offset)) input_domain = input_x.domain m = input_x.container diff --git a/tests/unit/test_formulation.py b/tests/unit/test_formulation.py index 3621aedf..97b37139 100644 --- a/tests/unit/test_formulation.py +++ b/tests/unit/test_formulation.py @@ -85,6 +85,9 @@ def test_pwl_indicator(): x3 = gp.Variable(m, name="x3", domain=[k]) x4 = gp.Variable(m, name="x4", domain=[i, k]) + b3 = gp.Variable(m, name="b3", type="binary") + x5 = gp.Variable(m, name="x5") + pytest.raises( ValidationError, piecewise._indicator, "indicator_var", 0, x <= 10 ) @@ -103,6 +106,9 @@ def test_pwl_indicator(): assert len(eqs1) == len(eqs2) assert len(eqs3) == len(eqs1) * 2 + eqs4 = piecewise._indicator(b3, 1, x5 >= 10) + assert len(eqs4) == len(eqs1) + var_count = get_var_count_by_type(m) assert "sos1" in var_count From e4749eccaa599bf2903ccb128223bd3fe343de65 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Tue, 7 Jan 2025 19:53:10 +0300 Subject: [PATCH 106/135] replace zero arrays with None --- src/gamspy/formulations/shape.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index 6dd6f755..02944e88 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -74,10 +74,11 @@ def _propagate_bounds(x, out): # reshape bounds based on the output variable's shape # when bounds.records is None, it means the bounds are zeros - if bounds.records is not None: - nb_data = bounds.toDense().reshape((2,) + out.shape) - else: - nb_data = np.zeros(bounds.shape).reshape((2,) + out.shape) + nb_data = ( + None + if bounds.records is None + else bounds.toDense().reshape((2,) + out.shape) + ) # set new domain for bounds nb_dom = [bounds_set, *out.domain] From 090cbff9101d29a09e2f6605fe20bb55b383486c Mon Sep 17 00:00:00 2001 From: aalqershi Date: Tue, 7 Jan 2025 20:16:25 +0300 Subject: [PATCH 107/135] apply suggestion; only reshape data if its not None --- src/gamspy/formulations/shape.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/gamspy/formulations/shape.py b/src/gamspy/formulations/shape.py index 02944e88..ac90a44f 100644 --- a/src/gamspy/formulations/shape.py +++ b/src/gamspy/formulations/shape.py @@ -3,8 +3,6 @@ import math import uuid -import numpy as np - import gamspy as gp import gamspy.formulations.nn.utils as utils from gamspy.exceptions import ValidationError @@ -35,14 +33,13 @@ def _flatten_dims_par( ) -> tuple[gp.Parameter, list[gp.Equation]]: data = x.toDense() - if data is None: - data = np.zeros(x.shape) - m = x.container new_domain, _ = _get_new_domain(x, dims) new_shape = [len(d) for d in new_domain] - data = data.reshape(new_shape) + + if data is not None: + data = data.reshape(new_shape) out = m.addParameter(domain=new_domain, records=data) return out, [] From 305978badbb030927bfe5958a7630913f9aac84b Mon Sep 17 00:00:00 2001 From: aalqershi Date: Tue, 7 Jan 2025 20:24:57 +0300 Subject: [PATCH 108/135] test to flatten a parameter with no assigned records --- tests/unit/test_nn_formulation.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index 006bfeb5..eed5e6d9 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -1811,6 +1811,19 @@ def test_flatten_par(data): assert eqs == [] # for parameters no equation needed +def test_flatten_par_with_no_records(data): + m, *_ = data + + par = gp.Parameter(m, "par", domain=dim([10, 5])) + + # 10x5 -> 50 + par_flattened, eqs = flatten_dims(par, [0, 1]) + + assert par_flattened.toDense() is None + assert par_flattened.shape == (50,) + assert eqs == [] # for parameters no equation needed + + def test_flatten_var_copied_domain(data): m, w1, b1, inp, par_input, ii = data From 69a1dac824cabf8cc66dba8ba702454dfee4b519 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Tue, 7 Jan 2025 21:48:30 +0300 Subject: [PATCH 109/135] save cpu by skipping matrix operations when bounds are zero --- src/gamspy/formulations/nn/linear.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/gamspy/formulations/nn/linear.py b/src/gamspy/formulations/nn/linear.py index 01bc6090..9c040732 100644 --- a/src/gamspy/formulations/nn/linear.py +++ b/src/gamspy/formulations/nn/linear.py @@ -250,10 +250,23 @@ def __call__( x_bounds[("0",) + tuple(input.domain)] = input.lo[...] x_bounds[("1",) + tuple(input.domain)] = input.up[...] - if x_bounds.records is not None: - x_lb, x_ub = x_bounds.toDense() - else: - x_lb, x_ub = np.zeros(x_bounds.shape) + # If the bounds are all zeros (None in GAMSPy parameters); + # we skip matrix multiplication as it will result in zero values + if x_bounds.records is None: + if self.use_bias: + out_bounds_array = np.zeros(out.shape) + self.bias_array + out_bounds = gp.Parameter( + self.container, + out_bounds_name, + domain=dim(out.shape), + records=out_bounds_array, + ) + out.lo[...] = out_bounds + out.up[...] = out_bounds + + return out, [set_out] + + x_lb, x_ub = x_bounds.toDense() # To deal with infinity values in the input bounds, we convert them into complex numbers # where if the value is -inf, we convert it to 0 - 1j From 656976e4af0be78455fdbad74352888ebe9dd7d8 Mon Sep 17 00:00:00 2001 From: aalqershi Date: Tue, 7 Jan 2025 22:12:36 +0300 Subject: [PATCH 110/135] when theres no bias, set bounds to zero --- src/gamspy/formulations/nn/linear.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/gamspy/formulations/nn/linear.py b/src/gamspy/formulations/nn/linear.py index 9c040732..b6a5c167 100644 --- a/src/gamspy/formulations/nn/linear.py +++ b/src/gamspy/formulations/nn/linear.py @@ -253,16 +253,19 @@ def __call__( # If the bounds are all zeros (None in GAMSPy parameters); # we skip matrix multiplication as it will result in zero values if x_bounds.records is None: + out_bounds_array = np.zeros(out.shape) + if self.use_bias: - out_bounds_array = np.zeros(out.shape) + self.bias_array - out_bounds = gp.Parameter( - self.container, - out_bounds_name, - domain=dim(out.shape), - records=out_bounds_array, - ) - out.lo[...] = out_bounds - out.up[...] = out_bounds + out_bounds_array = out_bounds_array + self.bias_array + + out_bounds = gp.Parameter( + self.container, + out_bounds_name, + domain=dim(out.shape), + records=out_bounds_array, + ) + out.lo[...] = out_bounds + out.up[...] = out_bounds return out, [set_out] From 5bf1a833a70157a8086cbee1f4a2fad1457bf2bf Mon Sep 17 00:00:00 2001 From: Hamdi Burak Usul Date: Tue, 7 Jan 2025 21:07:42 +0100 Subject: [PATCH 111/135] minor update in tests --- tests/unit/test_nn_formulation.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/test_nn_formulation.py b/tests/unit/test_nn_formulation.py index eed5e6d9..514c7a05 100644 --- a/tests/unit/test_nn_formulation.py +++ b/tests/unit/test_nn_formulation.py @@ -2393,3 +2393,18 @@ def test_linear_propagate_zero_bounds(data): assert np.allclose( np.array(out1.records.lower).reshape(out1.shape), expected_bounds ) + + lin2 = Linear(m, 4, 3, bias=True) + b1 = np.random.rand(3) + lin2.load_weights(w1, b1) + + out2, _ = lin2(x) + + expected_bounds = np.stack((b1, b1)) + + assert np.allclose( + np.array(out2.records.upper).reshape(out1.shape), expected_bounds + ) + assert np.allclose( + np.array(out2.records.lower).reshape(out1.shape), expected_bounds + ) From bd8826c4254566e022abfc85f25cb52a2989ff29 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Wed, 8 Jan 2025 12:25:07 +0300 Subject: [PATCH 112/135] Fix solver options bug in frozen solve. --- CHANGELOG.md | 1 + src/gamspy/_model.py | 11 ++++---- src/gamspy/_model_instance.py | 36 +++++++++++++++--------- tests/integration/test_model_instance.py | 13 +++++++-- 4 files changed, 39 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8816df5..2ba9e949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ GAMSPy 1.4.1 - General - Fix implicit parameter validation bug. - Migrate GAMSPy CLI to Typer. + - Fix solver options bug in frozen solve. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. - Documentation diff --git a/src/gamspy/_model.py b/src/gamspy/_model.py index dfcf07c6..dabe3375 100644 --- a/src/gamspy/_model.py +++ b/src/gamspy/_model.py @@ -1039,6 +1039,9 @@ def freeze( """ self._is_frozen = True + if options is None: + options = Options() + self.instance = ModelInstance( self.container, self, modifiables, options ) @@ -1129,13 +1132,9 @@ def solve( options._frame = frame if self._is_frozen: - options._set_solver_options( - working_directory=self.container.working_directory, - solver=solver, - problem=self.problem, - solver_options=solver_options, + self.instance.solve( + solver, model_instance_options, solver_options, output ) - self.instance.solve(solver, model_instance_options, output) return None self.container._add_statement(self.getDeclaration()) diff --git a/src/gamspy/_model_instance.py b/src/gamspy/_model_instance.py index eca9c940..9987da99 100644 --- a/src/gamspy/_model_instance.py +++ b/src/gamspy/_model_instance.py @@ -81,7 +81,7 @@ def __init__( container: Container, model: Model, modifiables: list[Parameter | ImplicitParameter], - freeze_options: Options | None = None, + freeze_options: Options, ): self.container = container self.job_name = container._job @@ -106,10 +106,7 @@ def __init__( container.system_directory, debug=self._debugging_level, ) - self.checkpoint = GamsCheckpoint( - self.workspace, - self.save_file, - ) + self.checkpoint = GamsCheckpoint(self.workspace, self.save_file) self.instance = self.checkpoint.add_modelinstance() self.instantiate(model, freeze_options) @@ -143,7 +140,7 @@ def _create_restart_file(self): self.container._send_job(self.job_name, self.pf_file) - def instantiate(self, model: Model, options: Options | None = None): + def instantiate(self, model: Model, options: Options): modifiers = self._create_modifiers() solve_string = f"{model.name} using {model.problem}" @@ -161,10 +158,13 @@ def solve( self, solver: str | None, given_options: ModelInstanceOptions | None = None, + solver_options: dict | None = None, output: io.TextIOWrapper | None = None, ): # get options from dict - options, update_type = self._prepare_options(solver, given_options) + options, update_type = self._prepare_options( + solver, given_options, solver_options + ) # update sync_db self.container.write(self.instance.sync_db._gmd, eps_to_zero=False) @@ -253,13 +253,8 @@ def _init_modifiables( return will_be_modified - def _prepare_gams_options( - self, given_options: Options | None - ) -> GamsOptions: + def _prepare_gams_options(self, given_options: Options) -> GamsOptions: options = GamsOptions(self.workspace) - if given_options is None: - return options - options_dict = given_options._get_gams_compatible_options() for key, value in options_dict.items(): @@ -271,13 +266,26 @@ def _prepare_options( self, solver: str | None, given_options: ModelInstanceOptions | None, + solver_options: dict | None, ) -> tuple[GamsModelInstanceOpt | None, SymbolUpdateType]: update_type = SymbolUpdateType.BaseCase - options = GamsModelInstanceOpt() + opt_file = 1 if solver_options else -1 + options = GamsModelInstanceOpt(opt_file=opt_file) if solver is not None: options.solver = solver + if solver_options is not None: + solver_options_file_name = os.path.join( + self.container.working_directory, f"{solver.lower()}.opt" + ) + + with open( + solver_options_file_name, "w", encoding="utf-8" + ) as solver_file: + for key, value in solver_options.items(): + solver_file.write(f"{key} {value}\n") + if given_options is not None: for key, value in given_options.items(): setattr(options, key, value) diff --git a/tests/integration/test_model_instance.py b/tests/integration/test_model_instance.py index 8f048359..248ea80f 100644 --- a/tests/integration/test_model_instance.py +++ b/tests/integration/test_model_instance.py @@ -282,8 +282,17 @@ def test_validations(data): assert os.path.exists("gams.gms") # Test solver options - transport.solve(solver="conopt", solver_options={"rtmaxv": "1.e12"}) - assert os.path.exists(os.path.join(m.working_directory, "conopt.opt")) + with open("_out.txt", "w") as file: + transport.solve( + solver="conopt", output=file, solver_options={"rtmaxv": "1.e12"} + ) + + with open("_out.txt") as file: + assert ">> rtmaxv 1.e12" in file.read() + + options_path = os.path.join(m.working_directory, "conopt.opt") + assert os.path.exists(options_path) + os.remove(options_path) def test_modifiable_in_condition(data): From 1997881f1c7097ec6f7e101c75c841d1f3602ca9 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Wed, 8 Jan 2025 15:43:47 +0100 Subject: [PATCH 113/135] update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8839bc..f4b6d0f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GAMSPy 1.4.1 - Fix implicit parameter validation bug. - Allow the usage of Container as a context manager. - Migrate GAMSPy CLI to Typer. + - Threads can now create a container since we register the signal only to the main thread. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. - Documentation From fabae71acf26ed2c0ed3ed5cf0804acdda6da933 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Wed, 8 Jan 2025 16:17:08 +0100 Subject: [PATCH 114/135] simplify context manager logic --- src/gamspy/_symbols/alias.py | 36 ++++++++++++-------------- src/gamspy/_symbols/equation.py | 36 ++++++++++++-------------- src/gamspy/_symbols/parameter.py | 36 ++++++++++++-------------- src/gamspy/_symbols/set.py | 37 ++++++++++++--------------- src/gamspy/_symbols/universe_alias.py | 23 ++++++++++++++--- src/gamspy/_symbols/variable.py | 36 ++++++++++++-------------- 6 files changed, 100 insertions(+), 104 deletions(-) diff --git a/src/gamspy/_symbols/alias.py b/src/gamspy/_symbols/alias.py index 5277389f..59157c4e 100644 --- a/src/gamspy/_symbols/alias.py +++ b/src/gamspy/_symbols/alias.py @@ -81,13 +81,7 @@ def __new__( name: str | None = None, alias_with: Set | Alias | None = None, ): - ctx = None - try: - ctx = gp._ctx_managers[(os.getpid(), threading.get_native_id())] - except KeyError: - ... - - if ctx is None and not isinstance(container, gp.Container): + if container and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" @@ -99,18 +93,19 @@ def __new__( ) if name is None: - obj = object.__new__(cls) - - if container is None: - obj._ctx = ctx - return obj + return object.__new__(cls) else: if not isinstance(name, str): raise TypeError( f"Name must of type `str` but found {type(name)}" ) try: - symbol = ctx[name] if ctx is not None else container[name] # type: ignore + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + + symbol = container[name] if isinstance(symbol, cls): return symbol @@ -119,11 +114,7 @@ def __new__( " because it is not an Alias object)" ) except KeyError: - obj = object.__new__(cls) - - if container is None: - obj._ctx = ctx - return obj + return object.__new__(cls) def __init__( self, @@ -143,8 +134,13 @@ def __init__( self.modified = True self.alias_with = alias_with else: - if hasattr(self, "_ctx"): - container = self._ctx + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError("Set requires a container.") from e assert container is not None if name is not None: diff --git a/src/gamspy/_symbols/equation.py b/src/gamspy/_symbols/equation.py index 696ff108..d11ea4b1 100644 --- a/src/gamspy/_symbols/equation.py +++ b/src/gamspy/_symbols/equation.py @@ -171,30 +171,25 @@ def __new__( is_miro_output: bool = False, definition_domain: list | None = None, ): - ctx = None - try: - ctx = gp._ctx_managers[(os.getpid(), threading.get_native_id())] - except KeyError: - ... - - if ctx is None and not isinstance(container, gp.Container): + if container and not isinstance(container, gp.Container): raise TypeError( f"Container must of type `Container` but found {container}" ) if name is None: - obj = object.__new__(cls) - - if container is None: - obj._ctx = ctx - return obj + return object.__new__(cls) else: if not isinstance(name, str): raise TypeError( f"Name must of type `str` but found {builtins.type(name)}" ) try: - symbol = ctx[name] if ctx is not None else container[name] # type: ignore + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + + symbol = container[name] if isinstance(symbol, cls): return symbol @@ -203,11 +198,7 @@ def __new__( " because it is not an Equation object)" ) except KeyError: - obj = object.__new__(cls) - - if container is None: - obj._ctx = ctx - return obj + return object.__new__(cls) def __init__( self, @@ -288,8 +279,13 @@ def __init__( self.container._options.miro_protect = previous_state else: - if hasattr(self, "_ctx"): - container = self._ctx + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError("Set requires a container.") from e assert container is not None type = cast_type(type) diff --git a/src/gamspy/_symbols/parameter.py b/src/gamspy/_symbols/parameter.py index 1084dcf8..ce55b958 100644 --- a/src/gamspy/_symbols/parameter.py +++ b/src/gamspy/_symbols/parameter.py @@ -129,24 +129,14 @@ def __new__( is_miro_output: bool = False, is_miro_table: bool = False, ): - ctx = None - try: - ctx = gp._ctx_managers[(os.getpid(), threading.get_native_id())] - except KeyError: - ... - - if ctx is None and not isinstance(container, gp.Container): + if container and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" ) if name is None: - obj = object.__new__(cls) - - if container is None: - obj._ctx = ctx - return obj + return object.__new__(cls) else: if not isinstance(name, str): raise TypeError( @@ -154,7 +144,12 @@ def __new__( ) try: - symbol = ctx[name] if ctx is not None else container[name] # type: ignore + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + + symbol = container[name] if isinstance(symbol, cls): return symbol @@ -163,11 +158,7 @@ def __new__( " because it is not a Parameter object" ) except KeyError: - obj = object.__new__(cls) - - if container is None: - obj._ctx = ctx - return obj + return object.__new__(cls) def __init__( self, @@ -244,8 +235,13 @@ def __init__( self.setRecords(records, uels_on_axes=uels_on_axes) self.container._options.miro_protect = previous_state else: - if hasattr(self, "_ctx"): - container = self._ctx + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError("Set requires a container.") from e assert container is not None if name is not None: diff --git a/src/gamspy/_symbols/set.py b/src/gamspy/_symbols/set.py index 6f7d7966..37ec8654 100644 --- a/src/gamspy/_symbols/set.py +++ b/src/gamspy/_symbols/set.py @@ -521,31 +521,27 @@ def __new__( is_miro_input: bool = False, is_miro_output: bool = False, ): - ctx = None - try: - ctx = gp._ctx_managers[(os.getpid(), threading.get_native_id())] - except KeyError: - ... - - if ctx is None and not isinstance(container, gp.Container): + if container and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" ) if name is None: - obj = object.__new__(cls) - - if container is None: - obj._ctx = ctx - return obj + return object.__new__(cls) else: if not isinstance(name, str): raise TypeError( f"Name must of type `str` but found {type(name)}" ) try: - symbol = ctx[name] if ctx is not None else container[name] # type: ignore + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + + symbol = container[name] + if isinstance(symbol, cls): return symbol @@ -554,11 +550,7 @@ def __new__( " because it is not a Set object)" ) except KeyError: - obj = object.__new__(cls) - - if container is None: - obj._ctx = ctx - return obj + return object.__new__(cls) def __init__( self, @@ -635,8 +627,13 @@ def __init__( self.container._options.miro_protect = previous_state else: - if hasattr(self, "_ctx"): - container = self._ctx + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError("Set requires a container.") from e assert container is not None self.where = condition.Condition(self) diff --git a/src/gamspy/_symbols/universe_alias.py b/src/gamspy/_symbols/universe_alias.py index 1dea7eb3..138680b8 100644 --- a/src/gamspy/_symbols/universe_alias.py +++ b/src/gamspy/_symbols/universe_alias.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import threading from typing import TYPE_CHECKING import gams.transfer as gt @@ -66,8 +68,10 @@ def _constructor_bypass(cls, container: Container, name: str): return obj - def __new__(cls, container: Container, name: str = "universe"): - if not isinstance(container, gp.Container): + def __new__( + cls, container: Container | None = None, name: str = "universe" + ): + if container and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" @@ -77,7 +81,12 @@ def __new__(cls, container: Container, name: str = "universe"): raise TypeError(f"Name must of type `str` but found {type(name)}") try: - symbol = container[name] + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + + symbol = container[name] if isinstance(symbol, cls): return symbol @@ -88,9 +97,15 @@ def __new__(cls, container: Container, name: str = "universe"): except KeyError: return object.__new__(cls) - def __init__(self, container: Container, name: str = "universe"): + def __init__( + self, container: Container | None = None, name: str = "universe" + ): # check if the name is a reserved word name = validation.validate_name(name) + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] super().__init__(container, name) diff --git a/src/gamspy/_symbols/variable.py b/src/gamspy/_symbols/variable.py index 171ca8c0..f103ece4 100644 --- a/src/gamspy/_symbols/variable.py +++ b/src/gamspy/_symbols/variable.py @@ -162,31 +162,26 @@ def __new__( uels_on_axes: bool = False, is_miro_output: bool = False, ): - ctx = None - try: - ctx = gp._ctx_managers[(os.getpid(), threading.get_native_id())] - except KeyError: - ... - - if ctx is None and not isinstance(container, gp.Container): + if container and not isinstance(container, gp.Container): invalid_type = builtins.type(container) raise TypeError( f"Container must of type `Container` but found {invalid_type}" ) if name is None: - obj = object.__new__(cls) - - if container is None: - obj._ctx = ctx - return obj + return object.__new__(cls) else: if not isinstance(name, str): raise TypeError( f"Name must of type `str` but found {builtins.type(name)}" ) try: - symbol = ctx[name] if ctx is not None else container[name] # type: ignore + if not container: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + + symbol = container[name] if isinstance(symbol, cls): return symbol @@ -195,11 +190,7 @@ def __new__( " because it is not a Variable object)" ) except KeyError: - obj = object.__new__(cls) - - if container is None: - obj._ctx = ctx - return obj + return object.__new__(cls) def __init__( self, @@ -281,8 +272,13 @@ def __init__( self.container._options.miro_protect = previous_state else: - if hasattr(self, "_ctx"): - container = self._ctx + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError("Set requires a container.") from e assert container is not None type = cast_type(type) From 7cfefdae99883043cec9bc55d11fbeabefe35c47 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Wed, 8 Jan 2025 16:23:30 +0100 Subject: [PATCH 115/135] fix typo --- src/gamspy/_symbols/alias.py | 2 +- src/gamspy/_symbols/equation.py | 4 +++- src/gamspy/_symbols/parameter.py | 4 +++- src/gamspy/_symbols/variable.py | 4 +++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/gamspy/_symbols/alias.py b/src/gamspy/_symbols/alias.py index 59157c4e..e2386f55 100644 --- a/src/gamspy/_symbols/alias.py +++ b/src/gamspy/_symbols/alias.py @@ -140,7 +140,7 @@ def __init__( (os.getpid(), threading.get_native_id()) ] except KeyError as e: - raise ValidationError("Set requires a container.") from e + raise ValidationError("Alias requires a container.") from e assert container is not None if name is not None: diff --git a/src/gamspy/_symbols/equation.py b/src/gamspy/_symbols/equation.py index d11ea4b1..fdfa2b49 100644 --- a/src/gamspy/_symbols/equation.py +++ b/src/gamspy/_symbols/equation.py @@ -285,7 +285,9 @@ def __init__( (os.getpid(), threading.get_native_id()) ] except KeyError as e: - raise ValidationError("Set requires a container.") from e + raise ValidationError( + "Equation requires a container." + ) from e assert container is not None type = cast_type(type) diff --git a/src/gamspy/_symbols/parameter.py b/src/gamspy/_symbols/parameter.py index ce55b958..8369032a 100644 --- a/src/gamspy/_symbols/parameter.py +++ b/src/gamspy/_symbols/parameter.py @@ -241,7 +241,9 @@ def __init__( (os.getpid(), threading.get_native_id()) ] except KeyError as e: - raise ValidationError("Set requires a container.") from e + raise ValidationError( + "Parameter requires a container." + ) from e assert container is not None if name is not None: diff --git a/src/gamspy/_symbols/variable.py b/src/gamspy/_symbols/variable.py index f103ece4..95b93d68 100644 --- a/src/gamspy/_symbols/variable.py +++ b/src/gamspy/_symbols/variable.py @@ -278,7 +278,9 @@ def __init__( (os.getpid(), threading.get_native_id()) ] except KeyError as e: - raise ValidationError("Set requires a container.") from e + raise ValidationError( + "Variable requires a container." + ) from e assert container is not None type = cast_type(type) From b8ad6e42971cdc42dd9fcb49566e3696e62703c2 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Wed, 8 Jan 2025 16:56:39 +0100 Subject: [PATCH 116/135] fix universe alias issue --- src/gamspy/_symbols/universe_alias.py | 16 +++++++++++----- tests/unit/test_alias.py | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/gamspy/_symbols/universe_alias.py b/src/gamspy/_symbols/universe_alias.py index 138680b8..f982e808 100644 --- a/src/gamspy/_symbols/universe_alias.py +++ b/src/gamspy/_symbols/universe_alias.py @@ -10,6 +10,7 @@ import gamspy as gp import gamspy._algebra.condition as condition import gamspy._validation as validation +from gamspy.exceptions import ValidationError if TYPE_CHECKING: from gamspy import Container @@ -86,7 +87,7 @@ def __new__( (os.getpid(), threading.get_native_id()) ] - symbol = container[name] + symbol = container[name] if isinstance(symbol, cls): return symbol @@ -102,10 +103,15 @@ def __init__( ): # check if the name is a reserved word name = validation.validate_name(name) - if not container: - container = gp._ctx_managers[ - (os.getpid(), threading.get_native_id()) - ] + if container is None: + try: + container = gp._ctx_managers[ + (os.getpid(), threading.get_native_id()) + ] + except KeyError as e: + raise ValidationError( + "UniverseAlias requires a container." + ) from e super().__init__(container, name) diff --git a/tests/unit/test_alias.py b/tests/unit/test_alias.py index 915ff1f0..f72b4ec5 100644 --- a/tests/unit/test_alias.py +++ b/tests/unit/test_alias.py @@ -125,7 +125,7 @@ def test_universe_alias_creation(m): _ = UniverseAlias(m, 5) # no container - pytest.raises(TypeError, UniverseAlias) + pytest.raises(ValidationError, UniverseAlias) # non-container type container pytest.raises(TypeError, UniverseAlias, 5, "j") From 5c813fabff78acdea2564ee58b7a4b5021733dbf Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Wed, 8 Jan 2025 17:10:19 +0100 Subject: [PATCH 117/135] update the tests for container requirement --- tests/unit/test_equation.py | 2 +- tests/unit/test_variable.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_equation.py b/tests/unit/test_equation.py index ee289df7..f0aa03f7 100644 --- a/tests/unit/test_equation.py +++ b/tests/unit/test_equation.py @@ -59,7 +59,7 @@ def test_equation_creation(data): pytest.raises(TypeError, Equation, m, 5) # no container - pytest.raises(TypeError, Equation) + pytest.raises(ValidationError, Equation) # non-container type container pytest.raises(TypeError, Equation, 5, "j") diff --git a/tests/unit/test_variable.py b/tests/unit/test_variable.py index 3bba5078..2de1c663 100644 --- a/tests/unit/test_variable.py +++ b/tests/unit/test_variable.py @@ -51,7 +51,7 @@ def test_variable_creation(data): pytest.raises(TypeError, Variable, m, 5) # no container - pytest.raises(TypeError, Variable) + pytest.raises(ValidationError, Variable) # non-container type container pytest.raises(TypeError, Variable, 5, "j") From 49d1037745a460b5c89e1320bd1a8fb0e0500096 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Wed, 8 Jan 2025 21:01:58 +0300 Subject: [PATCH 118/135] check for none explicitly --- src/gamspy/_symbols/alias.py | 2 +- src/gamspy/_symbols/equation.py | 2 +- src/gamspy/_symbols/parameter.py | 2 +- src/gamspy/_symbols/set.py | 2 +- src/gamspy/_symbols/universe_alias.py | 2 +- src/gamspy/_symbols/variable.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/gamspy/_symbols/alias.py b/src/gamspy/_symbols/alias.py index e2386f55..8163aa47 100644 --- a/src/gamspy/_symbols/alias.py +++ b/src/gamspy/_symbols/alias.py @@ -81,7 +81,7 @@ def __new__( name: str | None = None, alias_with: Set | Alias | None = None, ): - if container and not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" diff --git a/src/gamspy/_symbols/equation.py b/src/gamspy/_symbols/equation.py index fdfa2b49..4882c6a9 100644 --- a/src/gamspy/_symbols/equation.py +++ b/src/gamspy/_symbols/equation.py @@ -171,7 +171,7 @@ def __new__( is_miro_output: bool = False, definition_domain: list | None = None, ): - if container and not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): raise TypeError( f"Container must of type `Container` but found {container}" ) diff --git a/src/gamspy/_symbols/parameter.py b/src/gamspy/_symbols/parameter.py index 8369032a..3167bbb5 100644 --- a/src/gamspy/_symbols/parameter.py +++ b/src/gamspy/_symbols/parameter.py @@ -129,7 +129,7 @@ def __new__( is_miro_output: bool = False, is_miro_table: bool = False, ): - if container and not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" diff --git a/src/gamspy/_symbols/set.py b/src/gamspy/_symbols/set.py index 37ec8654..64e1fca6 100644 --- a/src/gamspy/_symbols/set.py +++ b/src/gamspy/_symbols/set.py @@ -521,7 +521,7 @@ def __new__( is_miro_input: bool = False, is_miro_output: bool = False, ): - if container and not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" diff --git a/src/gamspy/_symbols/universe_alias.py b/src/gamspy/_symbols/universe_alias.py index f982e808..3e4015ba 100644 --- a/src/gamspy/_symbols/universe_alias.py +++ b/src/gamspy/_symbols/universe_alias.py @@ -72,7 +72,7 @@ def _constructor_bypass(cls, container: Container, name: str): def __new__( cls, container: Container | None = None, name: str = "universe" ): - if container and not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): raise TypeError( "Container must of type `Container` but found" f" {type(container)}" diff --git a/src/gamspy/_symbols/variable.py b/src/gamspy/_symbols/variable.py index 95b93d68..7751ab2b 100644 --- a/src/gamspy/_symbols/variable.py +++ b/src/gamspy/_symbols/variable.py @@ -162,7 +162,7 @@ def __new__( uels_on_axes: bool = False, is_miro_output: bool = False, ): - if container and not isinstance(container, gp.Container): + if container is not None and not isinstance(container, gp.Container): invalid_type = builtins.type(container) raise TypeError( f"Container must of type `Container` but found {invalid_type}" From bdb9f112c359018820fa38f9e8da48e5a4d30149 Mon Sep 17 00:00:00 2001 From: msoyturk Date: Wed, 8 Jan 2025 21:19:32 +0300 Subject: [PATCH 119/135] Synchronize after read. --- CHANGELOG.md | 1 + src/gamspy/_container.py | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc516f3..835f4f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ GAMSPy 1.4.1 - Add piecewise linear function formulations. - Migrate GAMSPy CLI to Typer. - Fix solver options bug in frozen solve. + - Synchronize after read. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. - Add tests for piecewise linear functions. diff --git a/src/gamspy/_container.py b/src/gamspy/_container.py index d747558e..81e22891 100644 --- a/src/gamspy/_container.py +++ b/src/gamspy/_container.py @@ -263,7 +263,7 @@ def __init__( ) if load_from is not None: - self.read(load_from) + self._read(load_from) if not isinstance(load_from, gt.Container): self._unsaved_statements = [] self._clean_modified_symbols() @@ -597,7 +597,7 @@ def _load_records_from_gdx( if updated_records is not None: self[name].domain_labels = self[name].domain_names else: - self.read(load_from, [name]) + self._read(load_from, [name]) if user_invoked: self[name].modified = True @@ -607,6 +607,17 @@ def _load_records_from_gdx( if user_invoked: self._synch_with_gams() + def _read( + self, + load_from: str | Container | gt.Container, + symbol_names: list[str] | None = None, + load_records: bool = True, + mode: str | None = None, + encoding: str | None = None, + ) -> None: + super().read(load_from, symbol_names, load_records, mode, encoding) + self._cast_symbols(symbol_names) + def read( self, load_from: str | Container | gt.Container, @@ -639,8 +650,8 @@ def read( True """ - super().read(load_from, symbol_names, load_records, mode, encoding) - self._cast_symbols(symbol_names) + self._read(load_from, symbol_names, load_records, mode, encoding) + self._synch_with_gams() def write( self, @@ -1176,7 +1187,7 @@ def copy(self, working_directory: str) -> Container: ) self.write(m._job + "in.gdx") - m.read(m._job + "in.gdx") + m._read(m._job + "in.gdx") # if already defined equations exist, add them to .gms file for equation in self.getEquations(): From d1a4fc479e1d325ed8ac021e4aa0bd8cd62794dc Mon Sep 17 00:00:00 2001 From: msoyturk Date: Wed, 8 Jan 2025 21:29:15 +0300 Subject: [PATCH 120/135] add read synch test --- tests/unit/test_container.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/unit/test_container.py b/tests/unit/test_container.py index 7f206f6f..e4bc50d9 100644 --- a/tests/unit/test_container.py +++ b/tests/unit/test_container.py @@ -188,6 +188,35 @@ def test_read_write(data): assert list(m.data.keys()) == ["k", "i"] +def test_read_synch(): + m = Container() + + j = Set( + m, + name="j", + records=["new-york", "chicago", "topeka"], + description="markets", + ) + _ = Set( + m, + name="j_sub", + records=["new-york", "chicago"], + domain=j, + description="markets", + ) + + gdx_file = "test.gdx" + m.write(gdx_file) + m = Container() + m.read(gdx_file) + + assert m["j"].toList() == ["new-york", "chicago", "topeka"] + assert m["j_sub"].toList() == ["new-york", "chicago"] + m["j_sub"][m["j"]] = False + + os.remove("test.gdx") + + def test_loadRecordsFromGdx(data): m, *_ = data gdx_path = os.path.join("tmp", "test.gdx") From 118d218fb6badf3cb27ebdfaf092e8dcc2b927ed Mon Sep 17 00:00:00 2001 From: msoyturk Date: Wed, 8 Jan 2025 21:31:27 +0300 Subject: [PATCH 121/135] add main function to piecewiselinear test to make it compatible with the performance script --- tests/integration/models/piecewiseLinear.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/models/piecewiseLinear.py b/tests/integration/models/piecewiseLinear.py index 203cb8bf..7dcdacf8 100644 --- a/tests/integration/models/piecewiseLinear.py +++ b/tests/integration/models/piecewiseLinear.py @@ -467,8 +467,12 @@ def indicator_suite(): m.close() -if __name__ == "__main__": +def main(): print("Piecewise linear function test model") pwl_suite(piecewise.pwl_convexity_formulation, "convexity") pwl_suite(piecewise.pwl_interval_formulation, "interval") indicator_suite() + + +if __name__ == "__main__": + main() From a42103f3167dd21462e92ea98aa4ab9d9af40f65 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Fri, 10 Jan 2025 15:50:26 +0100 Subject: [PATCH 122/135] add release notes --- CHANGELOG.md | 3 +- docs/_static/switcher.json | 98 +--------------------------------- docs/release/index.rst | 1 + docs/release/release_1.5.0.rst | 27 ++++++++++ pyproject.toml | 6 +-- tests/test_gamspy.py | 2 +- 6 files changed, 35 insertions(+), 102 deletions(-) create mode 100644 docs/release/release_1.5.0.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a47cc5f..bd7dd89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ GAMSPy CHANGELOG ================ -GAMSPy 1.4.1 +GAMSPy 1.5.0 ------------ - General - Fix implicit parameter validation bug. @@ -12,6 +12,7 @@ GAMSPy 1.4.1 - Threads can now create a container since we register the signal only to the main thread. - Fix solver options bug in frozen solve. - Synchronize after read. + - Upgrade gamspy_base and gamsapi dependencies. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. - Add tests for piecewise linear functions. diff --git a/docs/_static/switcher.json b/docs/_static/switcher.json index 0dc3a70a..01a76fc4 100644 --- a/docs/_static/switcher.json +++ b/docs/_static/switcher.json @@ -1,97 +1 @@ -[ - { - "name": "latest", - "version": "latest", - "url": "https://gamspy.readthedocs.io/en/latest/" - }, - { - "name": "1.4.0 (stable)", - "version": "v1.4.0", - "url": "https://gamspy.readthedocs.io/en/v1.4.0/" - }, - { - "name": "1.3.1", - "version": "v1.3.1", - "url": "https://gamspy.readthedocs.io/en/v1.3.1/" - }, - { - "name": "1.3.0", - "version": "v1.3.0", - "url": "https://gamspy.readthedocs.io/en/v1.3.0/" - }, - { - "name": "1.2.0", - "version": "v1.2.0", - "url": "https://gamspy.readthedocs.io/en/v1.2.0/" - }, - { - "name": "1.1.0", - "version": "v1.1.0", - "url": "https://gamspy.readthedocs.io/en/v1.1.0/" - }, - { - "name": "1.0.4", - "version": "v1.0.4", - "url": "https://gamspy.readthedocs.io/en/v1.0.4/" - }, - { - "name": "1.0.3", - "version": "v1.0.3", - "url": "https://gamspy.readthedocs.io/en/v1.0.3/" - }, - { - "name": "1.0.2", - "version": "v1.0.2", - "url": "https://gamspy.readthedocs.io/en/v1.0.2/" - }, - { - "name": "1.0.1", - "version": "v1.0.1", - "url": "https://gamspy.readthedocs.io/en/v1.0.1/" - }, - { - "name": "1.0.0", - "version": "v1.0.0", - "url": "https://gamspy.readthedocs.io/en/v1.0.0/" - }, - { - "name": "0.14.7", - "version": "v0.14.7", - "url": "https://gamspy.readthedocs.io/en/v0.14.7/" - }, - { - "name": "0.14.6", - "version": "v0.14.6", - "url": "https://gamspy.readthedocs.io/en/v0.14.6/" - }, - { - "name": "0.14.5", - "version": "v0.14.5", - "url": "https://gamspy.readthedocs.io/en/v0.14.5/" - }, - { - "name": "0.14.4", - "version": "v0.14.4", - "url": "https://gamspy.readthedocs.io/en/v0.14.4/" - }, - { - "name": "0.14.3", - "version": "v0.14.3", - "url": "https://gamspy.readthedocs.io/en/v0.14.3/" - }, - { - "name": "0.14.2", - "version": "v0.14.2", - "url": "https://gamspy.readthedocs.io/en/v0.14.2/" - }, - { - "name": "0.14.1", - "version": "v0.14.1", - "url": "https://gamspy.readthedocs.io/en/v0.14.1/" - }, - { - "name": "0.14.0", - "version": "v0.14.0", - "url": "https://gamspy.readthedocs.io/en/v0.14.0/" - } -] \ No newline at end of file +[{"name": "latest", "version": "latest", "url": "https://gamspy.readthedocs.io/en/latest/"}, {"name": "1.5.0 (stable)", "version": "v1.5.0", "url": "https://gamspy.readthedocs.io/en/v1.5.0/"}, {"name": "1.4.0", "version": "v1.4.0", "url": "https://gamspy.readthedocs.io/en/v1.4.0/"}, {"name": "1.3.1", "version": "v1.3.1", "url": "https://gamspy.readthedocs.io/en/v1.3.1/"}, {"name": "1.3.0", "version": "v1.3.0", "url": "https://gamspy.readthedocs.io/en/v1.3.0/"}, {"name": "1.2.0", "version": "v1.2.0", "url": "https://gamspy.readthedocs.io/en/v1.2.0/"}, {"name": "1.1.0", "version": "v1.1.0", "url": "https://gamspy.readthedocs.io/en/v1.1.0/"}, {"name": "1.0.4", "version": "v1.0.4", "url": "https://gamspy.readthedocs.io/en/v1.0.4/"}, {"name": "1.0.3", "version": "v1.0.3", "url": "https://gamspy.readthedocs.io/en/v1.0.3/"}, {"name": "1.0.2", "version": "v1.0.2", "url": "https://gamspy.readthedocs.io/en/v1.0.2/"}, {"name": "1.0.1", "version": "v1.0.1", "url": "https://gamspy.readthedocs.io/en/v1.0.1/"}, {"name": "1.0.0", "version": "v1.0.0", "url": "https://gamspy.readthedocs.io/en/v1.0.0/"}, {"name": "0.14.7", "version": "v0.14.7", "url": "https://gamspy.readthedocs.io/en/v0.14.7/"}, {"name": "0.14.6", "version": "v0.14.6", "url": "https://gamspy.readthedocs.io/en/v0.14.6/"}, {"name": "0.14.5", "version": "v0.14.5", "url": "https://gamspy.readthedocs.io/en/v0.14.5/"}, {"name": "0.14.4", "version": "v0.14.4", "url": "https://gamspy.readthedocs.io/en/v0.14.4/"}, {"name": "0.14.3", "version": "v0.14.3", "url": "https://gamspy.readthedocs.io/en/v0.14.3/"}, {"name": "0.14.2", "version": "v0.14.2", "url": "https://gamspy.readthedocs.io/en/v0.14.2/"}, {"name": "0.14.1", "version": "v0.14.1", "url": "https://gamspy.readthedocs.io/en/v0.14.1/"}, {"name": "0.14.0", "version": "v0.14.0", "url": "https://gamspy.readthedocs.io/en/v0.14.0/"}] \ No newline at end of file diff --git a/docs/release/index.rst b/docs/release/index.rst index 91964578..7044b1ad 100644 --- a/docs/release/index.rst +++ b/docs/release/index.rst @@ -7,6 +7,7 @@ Release Notes .. toctree:: :maxdepth: 1 + release_1.5.0 release_1.4.0 release_1.3.1 release_1.3.0 diff --git a/docs/release/release_1.5.0.rst b/docs/release/release_1.5.0.rst new file mode 100644 index 00000000..487d3a76 --- /dev/null +++ b/docs/release/release_1.5.0.rst @@ -0,0 +1,27 @@ +GAMSPy 1.5.0 +------------ + +Release Date: 10.01.2025 + +- General + + - Fix implicit parameter validation bug. + - Allow the usage of Container as a context manager. + - Allow propagating bounds to the output variable in `flatten_dims` method. + - Add piecewise linear function formulations. + - Migrate GAMSPy CLI to Typer. + - Threads can now create a container since we register the signal only to the main thread. + - Fix solver options bug in frozen solve. + - Synchronize after read. + - Upgrade gamspy_base and gamsapi dependencies. + +- Testing + + - Lower the number of dices in the interrupt test and put a time limit to the solve. + - Add tests for piecewise linear functions. + +- Documentation + + - Install dependencies in the first cell of the example transportation notebook. + - Add Formulations page to list piecewise linear functions and nn formulations. + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index db56bc2d..7f9a3dc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gamspy" -version = "1.4.0" +version = "1.5.0" authors = [ { name = "GAMS Development Corporation", email = "support@gams.com" }, ] @@ -36,8 +36,8 @@ classifiers = [ "Operating System :: Microsoft :: Windows", ] dependencies = [ - "gamsapi[transfer,control] == 48.5.0", - "gamspy_base == 48.5.0", + "gamsapi[transfer,control] == 48.6.0", + "gamspy_base == 48.6.0", "pydantic >= 2.0", "certifi >= 2022.09.14", "urllib3 >= 2.0.7", diff --git a/tests/test_gamspy.py b/tests/test_gamspy.py index e3c2795b..13bb1f76 100644 --- a/tests/test_gamspy.py +++ b/tests/test_gamspy.py @@ -14,7 +14,7 @@ @pytest.mark.unit def test_version(): - assert gamspy.__version__ == "1.4.0" + assert gamspy.__version__ == "1.5.0" @pytest.mark.doc From ddd3cfe41297cb382850f6cf2dc8bd4f5ea9d363 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 14:20:40 +0100 Subject: [PATCH 123/135] add frozen solve test for big models --- src/gamspy/_cli/install.py | 2 +- src/gamspy/_model_instance.py | 9 +++- tests/integration/test_model_instance.py | 69 ++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index 65be9472..31961f44 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -57,7 +57,7 @@ def license( data = request.data.decode("utf-8", errors="replace") cmex_type = json.loads(data)["cmex_type"] - if not cmex_type.startswith("gamspy"): + if cmex_type not in ["gamspy", "gamspy++", "gamsall"]: raise ValidationError( f"Given access code `{alp_id} ({cmex_type})` is not valid for GAMSPy. " "Make sure that you use a GAMSPy license, not a GAMS license." diff --git a/src/gamspy/_model_instance.py b/src/gamspy/_model_instance.py index 9987da99..522c34c1 100644 --- a/src/gamspy/_model_instance.py +++ b/src/gamspy/_model_instance.py @@ -152,6 +152,13 @@ def instantiate(self, model: Model, options: Options): solve_string += f" {model._objective_variable.gamsRepr()}" gams_options = self._prepare_gams_options(options) + gams_options.license = utils._get_license_path( + self.container.system_directory + ) + if self.container._network_license: + gams_options._netlicense = os.path.join( + self.container._process_directory, "gamslice.dat" + ) self.instance.instantiate(solve_string, modifiers, gams_options) def solve( @@ -195,7 +202,7 @@ def solve( # update model status self.model._status = gp.ModelStatus(self.instance.model_status) - self.model._solve_status = self.instance.solver_status + self.model._solve_status = gp.SolveStatus(self.instance.solver_status) def _init_modifiables( self, modifiables: list[Parameter | ImplicitParameter] diff --git a/tests/integration/test_model_instance.py b/tests/integration/test_model_instance.py index 248ea80f..61cf643e 100644 --- a/tests/integration/test_model_instance.py +++ b/tests/integration/test_model_instance.py @@ -3,9 +3,13 @@ import glob import math import os +import platform +import subprocess +import sys import pandas as pd import pytest +from gams import GamsExceptionExecution from gamspy import ( Container, @@ -18,6 +22,7 @@ Product, Sense, Set, + SolveStatus, Sum, Variable, ) @@ -25,6 +30,13 @@ pytestmark = pytest.mark.integration +try: + from dotenv import load_dotenv + + load_dotenv(os.getcwd() + os.sep + ".env") +except Exception: + pass + @pytest.fixture def data(): @@ -557,3 +569,60 @@ def test_modifiable_with_domain(data): assert math.isclose( mymodel.objective_value, 32.36124699832342, rel_tol=1e-6 ) + + +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) +def test_license(): + subprocess.run( + [ + sys.executable, + "-m", + "gamspy", + "uninstall", + "license", + ] + ) + m = Container() + i = Set(m, "i", records=range(5000)) + p = Parameter(m, "p", domain=i) + p2 = Parameter(m, "p2", records=5) + p.generateRecords() + v1 = Variable(m, "v1", domain=i) + z = Variable(m, "z") + e1 = Equation(m, "e1", domain=i) + + e1[i] = p2 * v1[i] * p[i] >= z + model = Model( + m, name="my_model", equations=[e1], sense=Sense.MIN, objective=z + ) + with pytest.raises(GamsExceptionExecution): + model.freeze(modifiables=[p2]) + + subprocess.run( + [ + sys.executable, + "-m", + "gamspy", + "install", + "license", + os.environ["MODEL_INSTANCE_LICENSE"], + ] + ) + + model.freeze(modifiables=[p2]) + model.solve() + assert model.solve_status == SolveStatus.NormalCompletion + + subprocess.run( + [ + sys.executable, + "-m", + "gamspy", + "install", + "license", + os.environ["LOCAL_LICENSE"], + ] + ) From 6c3de701360ffe8e150a574d533a32713f88f6f6 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 16:04:27 +0100 Subject: [PATCH 124/135] Add --checkout-duration and --renew options to gamspy install license. --- CHANGELOG.md | 1 + src/gamspy/_cli/install.py | 26 ++++++++++++++++--- tests/integration/test_cmd_script.py | 38 ++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd7dd89c..89ad45b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ GAMSPy 1.5.0 - Fix solver options bug in frozen solve. - Synchronize after read. - Upgrade gamspy_base and gamsapi dependencies. + - Add `--checkout-duration` and `--renew` options to `gamspy install license`. - Testing - Lower the number of dices in the interrupt test and put a time limit to the solve. - Add tests for piecewise linear functions. diff --git a/src/gamspy/_cli/install.py b/src/gamspy/_cli/install.py index 31961f44..6837205f 100644 --- a/src/gamspy/_cli/install.py +++ b/src/gamspy/_cli/install.py @@ -2,7 +2,7 @@ import importlib import shutil -from typing import Annotated, Iterable, Union +from typing import Annotated, Iterable, Optional, Union import typer from gamspy.exceptions import GamspyException, ValidationError @@ -24,7 +24,10 @@ ) def license( license: Annotated[str, typer.Argument(help="access code or path to the license file.")], - uses_port: Annotated[Union[int, None], typer.Option("--uses-port", help="Interprocess communication starting port.")] = None + checkout_duration: Optional[int] = typer.Option(None, "--checkout-duration", "-c", help="Specify a duration in hours to checkout a session."), + renew: Optional[str] = typer.Option(None, "--renew", "-r", help="Specify a file path to a license file to extend a session."), + output: Optional[str] = typer.Option(None, "--output", "-o", help="Specify a file path to write the license file."), + uses_port: Annotated[Union[int, None], typer.Option("--uses-port", help="Interprocess communication starting port.")] = None, ): import json from urllib.parse import urlencode @@ -69,6 +72,18 @@ def license( command.append("-u") command.append(str(uses_port)) + if checkout_duration: + command.append("-c") + command.append(str(checkout_duration)) + + if checkout_duration: + command.append("-r") + command.append(str(renew)) + + if output: + command.append("-o") + command.append(output) + process = subprocess.run( command, text=True, @@ -77,7 +92,12 @@ def license( if process.returncode: raise ValidationError(process.stderr) - license_text = process.stdout + if output: + with open(output) as file: + license_text = file.read() + else: + license_text = process.stdout + lines = license_text.splitlines() license_type = lines[0][54] if license_type == "+": diff --git a/tests/integration/test_cmd_script.py b/tests/integration/test_cmd_script.py index fc6b9cff..d58a77b4 100644 --- a/tests/integration/test_cmd_script.py +++ b/tests/integration/test_cmd_script.py @@ -134,6 +134,44 @@ def test_install_license(teardown): assert process.returncode == 0, process.stderr + # Test checkout + process = subprocess.run( + [ + "gamspy", + "install", + "license", + os.environ["CHECKOUT_LICENSE"], + "-c", + "1", + "-o", + tmp_license_path, + ], + capture_output=True, + text=True, + ) + + assert process.returncode == 0, process.stderr + + # Test renew + process = subprocess.run( + [ + "gamspy", + "install", + "license", + os.environ["CHECKOUT_LICENSE"], + "-c", + "1", + "-r", + tmp_license_path, + "-o", + tmp_license_path, + ], + capture_output=True, + text=True, + ) + + assert process.returncode == 0, process.stderr + # Recover local license process = subprocess.run( ["gamspy", "install", "license", os.environ["LOCAL_LICENSE"]], From 4fb22f56a4f1412773d33444337df4db5a3a1146 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 16:11:06 +0100 Subject: [PATCH 125/135] update model instance tests --- tests/integration/test_model_instance.py | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/integration/test_model_instance.py b/tests/integration/test_model_instance.py index 61cf643e..be3c4714 100644 --- a/tests/integration/test_model_instance.py +++ b/tests/integration/test_model_instance.py @@ -55,12 +55,34 @@ def data(): capacities = [["seattle", 350], ["san-diego", 600]] demands = [["new-york", 325], ["chicago", 300], ["topeka", 275]] + subprocess.run( + [ + sys.executable, + "-m", + "gamspy", + "install", + "license", + os.environ["MODEL_INSTANCE_LICENSE"], + ] + ) + # Act and assert yield m, canning_plants, markets, capacities, demands, distances # Cleanup m.close() + subprocess.run( + [ + sys.executable, + "-m", + "gamspy", + "install", + "license", + os.environ["LOCAL_LICENSE"], + ] + ) + files = glob.glob("_*") for file in files: os.remove(file) @@ -72,6 +94,10 @@ def data(): os.remove("gams.gms") +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_parameter_change(data): m, canning_plants, markets, capacities, demands, distances = data i = Set(m, name="i", records=canning_plants) @@ -154,6 +180,10 @@ def test_parameter_change(data): assert not transport._is_frozen +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_variable_change(data): m, canning_plants, markets, capacities, demands, distances = data i = Set(m, name="i", records=canning_plants) @@ -197,6 +227,10 @@ def test_variable_change(data): transport.unfreeze() +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_fx(data): m, *_ = data INCOME0 = Parameter( @@ -242,6 +276,10 @@ def test_fx(data): mm.unfreeze() +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_validations(data): m, canning_plants, markets, capacities, demands, distances = data i = Set(m, name="i", records=canning_plants) @@ -307,6 +345,10 @@ def test_validations(data): os.remove(options_path) +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_modifiable_in_condition(data): m, *_ = data td_data = pd.DataFrame( @@ -532,6 +574,10 @@ def test_modifiable_in_condition(data): war.freeze(modifiables=[x.l]) +@pytest.mark.skipif( + platform.system() == "Darwin", + reason="Darwin runners are not dockerized yet.", +) def test_modifiable_with_domain(data): m, *_ = data import gamspy as gp From 0013182d222f76bce93b11313f05fb5a5ff3e302 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 16:26:23 +0100 Subject: [PATCH 126/135] update model instance tests --- tests/integration/test_model_instance.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/test_model_instance.py b/tests/integration/test_model_instance.py index be3c4714..bc284382 100644 --- a/tests/integration/test_model_instance.py +++ b/tests/integration/test_model_instance.py @@ -69,7 +69,6 @@ def data(): # Act and assert yield m, canning_plants, markets, capacities, demands, distances - # Cleanup m.close() subprocess.run( From bf1c7b9a353867a7de24bae3b93e58f29abc9ff3 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 16:38:34 +0100 Subject: [PATCH 127/135] debug --- tests/integration/test_model_instance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_model_instance.py b/tests/integration/test_model_instance.py index bc284382..ac553955 100644 --- a/tests/integration/test_model_instance.py +++ b/tests/integration/test_model_instance.py @@ -41,7 +41,7 @@ @pytest.fixture def data(): # Arrange - m = Container() + m = Container(debugging_level="keep") canning_plants = ["seattle", "san-diego"] markets = ["new-york", "chicago", "topeka"] distances = [ From 9a907c89181713bc29b32f0722f3332b5ce7ff3f Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 16:47:13 +0100 Subject: [PATCH 128/135] debug --- tests/integration/test_model_instance.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/test_model_instance.py b/tests/integration/test_model_instance.py index ac553955..6f120db7 100644 --- a/tests/integration/test_model_instance.py +++ b/tests/integration/test_model_instance.py @@ -149,6 +149,9 @@ def test_parameter_change(data): transport.freeze(modifiables=[bmult]) + with open(os.path.join(m.working_directory, "_gams_py_gjo0.lst")) as file: + print(file.read()) + for b_value, result in zip(bmult_list, results): bmult[...] = b_value transport.solve(solver="conopt") From 39282d15743b9cb16153cd942f7a4c93834a75f7 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 16:51:27 +0100 Subject: [PATCH 129/135] debug --- tests/integration/test_model_instance.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/integration/test_model_instance.py b/tests/integration/test_model_instance.py index 6f120db7..48fca9f2 100644 --- a/tests/integration/test_model_instance.py +++ b/tests/integration/test_model_instance.py @@ -55,7 +55,7 @@ def data(): capacities = [["seattle", 350], ["san-diego", 600]] demands = [["new-york", 325], ["chicago", 300], ["topeka", 275]] - subprocess.run( + process = subprocess.run( [ sys.executable, "-m", @@ -63,15 +63,20 @@ def data(): "install", "license", os.environ["MODEL_INSTANCE_LICENSE"], - ] + ], + check=True, + capture_output=True, + text=True, ) + assert process.returncode == 0, process.stderr + # Act and assert yield m, canning_plants, markets, capacities, demands, distances m.close() - subprocess.run( + process = subprocess.run( [ sys.executable, "-m", @@ -79,9 +84,14 @@ def data(): "install", "license", os.environ["LOCAL_LICENSE"], - ] + ], + check=True, + capture_output=True, + text=True, ) + assert process.returncode == 0, process.stderr + files = glob.glob("_*") for file in files: os.remove(file) @@ -147,11 +157,14 @@ def test_parameter_change(data): 201.21750000000003, ] - transport.freeze(modifiables=[bmult]) + with pytest.raises(GamsExceptionExecution): + transport.freeze(modifiables=[bmult]) with open(os.path.join(m.working_directory, "_gams_py_gjo0.lst")) as file: print(file.read()) + pytest.fail() + for b_value, result in zip(bmult_list, results): bmult[...] = b_value transport.solve(solver="conopt") From 1a143faae74599800f38d0ea54ce5230dd2dbf26 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 17:08:49 +0100 Subject: [PATCH 130/135] update model instance test --- tests/integration/test_model_instance.py | 35 +++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/tests/integration/test_model_instance.py b/tests/integration/test_model_instance.py index 48fca9f2..f863cbe5 100644 --- a/tests/integration/test_model_instance.py +++ b/tests/integration/test_model_instance.py @@ -7,6 +7,7 @@ import subprocess import sys +import gamspy_base import pandas as pd import pytest from gams import GamsExceptionExecution @@ -40,6 +41,8 @@ @pytest.fixture def data(): + ci_license_path = os.path.join(gamspy_base.directory, "ci_license.txt") + # Arrange m = Container(debugging_level="keep") canning_plants = ["seattle", "san-diego"] @@ -63,6 +66,8 @@ def data(): "install", "license", os.environ["MODEL_INSTANCE_LICENSE"], + "-o", + ci_license_path, ], check=True, capture_output=True, @@ -84,6 +89,8 @@ def data(): "install", "license", os.environ["LOCAL_LICENSE"], + "-o", + ci_license_path, ], check=True, capture_output=True, @@ -157,13 +164,7 @@ def test_parameter_change(data): 201.21750000000003, ] - with pytest.raises(GamsExceptionExecution): - transport.freeze(modifiables=[bmult]) - - with open(os.path.join(m.working_directory, "_gams_py_gjo0.lst")) as file: - print(file.read()) - - pytest.fail() + transport.freeze(modifiables=[bmult]) for b_value, result in zip(bmult_list, results): bmult[...] = b_value @@ -637,6 +638,7 @@ def test_modifiable_with_domain(data): reason="Darwin runners are not dockerized yet.", ) def test_license(): + ci_license_path = os.path.join(gamspy_base.directory, "ci_license.txt") subprocess.run( [ sys.executable, @@ -644,7 +646,10 @@ def test_license(): "gamspy", "uninstall", "license", - ] + ], + check=True, + capture_output=True, + text=True, ) m = Container() i = Set(m, "i", records=range(5000)) @@ -670,7 +675,12 @@ def test_license(): "install", "license", os.environ["MODEL_INSTANCE_LICENSE"], - ] + "-o", + ci_license_path, + ], + check=True, + capture_output=True, + text=True, ) model.freeze(modifiables=[p2]) @@ -685,5 +695,10 @@ def test_license(): "install", "license", os.environ["LOCAL_LICENSE"], - ] + "-o", + ci_license_path, + ], + check=True, + capture_output=True, + text=True, ) From ca00b6bc80d71e609bdd97bc922ee2a4d15d0ff9 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 17:21:52 +0100 Subject: [PATCH 131/135] update doctest --- src/gamspy/_model.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/gamspy/_model.py b/src/gamspy/_model.py index 5bb3e457..f42b3e83 100644 --- a/src/gamspy/_model.py +++ b/src/gamspy/_model.py @@ -1020,32 +1020,6 @@ def freeze( modifiables: list[Parameter | ImplicitParameter], options: Options | None = None, ) -> None: - """ - Freezes all symbols except modifiable symbols. - - Parameters - ---------- - modifiables : List[Parameter | ImplicitParameter] - freeze_options : dict, optional - - Examples - -------- - >>> import gamspy as gp - >>> m = gp.Container() - >>> a = gp.Parameter(m, name="a", records=10) - >>> x = gp.Variable(m, name="x") - >>> e = gp.Equation(m, name="e", definition= x <= a) - >>> my_model = gp.Model(m, name="my_model", equations=m.getEquations(), problem="LP", sense="max", objective=x) - >>> solved = my_model.solve() - >>> float(x.toValue()) - 10.0 - >>> my_model.freeze(modifiables=[a]) - >>> a.setRecords(35) - >>> solved = my_model.solve() - >>> float(x.toValue()) - 35.0 - - """ self._is_frozen = True if options is None: options = Options() From 71b8b29d3c3f076dd41554e2b1b368c2fd8897e7 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 17:40:05 +0100 Subject: [PATCH 132/135] update link for the pwl reference --- src/gamspy/formulations/piecewise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 5bd8caf1..3683948b 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -35,7 +35,7 @@ def _enforce_sos2_with_binary(lambda_var: gp.Variable) -> list[gp.Equation]: Based on paper: `Modeling disjunctive constraints with a logarithmic number of binary variables and constraints - `_ + `_ """ equations: list[gp.Equation] = [] m = lambda_var.container From 7cb3779ccaad9676c8b12c728ffa428b139be717 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 17:57:20 +0100 Subject: [PATCH 133/135] update link for the pwl reference --- src/gamspy/formulations/piecewise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gamspy/formulations/piecewise.py b/src/gamspy/formulations/piecewise.py index 3683948b..0df34df9 100644 --- a/src/gamspy/formulations/piecewise.py +++ b/src/gamspy/formulations/piecewise.py @@ -553,7 +553,7 @@ def pwl_convexity_formulation( By default, SOS2 variables are implemented using binary variables. See `Modeling disjunctive constraints with a logarithmic number of binary variables and constraints - `_ + `_ . However, you can switch to SOS2 (Special Ordered Set Type 2) by setting the `using` parameter to `"sos2"`. From 50d75204f09f50dd19d7f4fbaed1c8a7e1064940 Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 18:00:44 +0100 Subject: [PATCH 134/135] fix doc link --- docs/user/faq.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/faq.rst b/docs/user/faq.rst index d74f81e5..76f147d8 100644 --- a/docs/user/faq.rst +++ b/docs/user/faq.rst @@ -278,4 +278,4 @@ which acts like a regular commandline script. This means that it cannot be signe sometimes thinks that it is probably a malware. Because of this issue, when you run commands such as `gamspy install license `, Windows Defender blocks the executable. A workaround is to run `python -m gamspy install license `. Another way is to whitelist ``gamspy.exe`` executable on your machine. Since GAMSPy is open source, to make sure about the safety of the executable, -one can check the following script which GAMSPy uses: `script `_. +one can check the following script which GAMSPy uses: `script `_. From f161ad962fb965105054857f1af2a70ee7075d5b Mon Sep 17 00:00:00 2001 From: mabdullahsoyturk Date: Mon, 13 Jan 2025 18:10:43 +0100 Subject: [PATCH 135/135] update cli docs --- docs/cli/install.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/cli/install.rst b/docs/cli/install.rst index 3eed1321..23fc7fab 100644 --- a/docs/cli/install.rst +++ b/docs/cli/install.rst @@ -29,6 +29,14 @@ Usage - - None - Interprocess communication starting port. Only relevant for local licenses that restrict concurrent use of GAMSPy. + * - -\-renew + - -r + - None + - Specify a file path to a license file to extend a session. + * - -\-checkout-duration + - -c + - None + - Specify a duration in hours to checkout a session. Examples ~~~~~~~~