pulp-lparray 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ .env
8
+ .ipynb_checkpoints/
9
+ .mypy_cache/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .worktrees/
13
+ *.png
14
+ *.npy
15
+ tags
@@ -0,0 +1,16 @@
1
+ MIT No Attribution
2
+
3
+ Copyright 2019-2026 Evgeny Naumov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this
6
+ software and associated documentation files (the "Software"), to deal in the Software
7
+ without restriction, including without limitation the rights to use, copy, modify,
8
+ merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
9
+ permit persons to whom the Software is furnished to do so.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
12
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
13
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
14
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
15
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
16
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.5
2
+ Name: pulp-lparray
3
+ Version: 2.0.0
4
+ Summary: PuLP x NumPy
5
+ License-Expression: MIT-0
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: numpy>=2
9
+ Requires-Dist: pulp>=4
10
+ Description-Content-Type: text/markdown
11
+
12
+ # pulp-lparray
13
+
14
+ *PuLP x NumPy, a match made in heaven.*
15
+
16
+ An `lparray` is a NumPy array of [PuLP](https://github.com/coin-or/pulp) variables, affine
17
+ expressions or constraints. Broadcasting, indexing, sums and matrix products build whole
18
+ families of expressions at once; `<=`, `>=` and `==` build families of constraints; and a
19
+ handful of methods linearise abs, max, min, argmax, top-k, logic and piecewise-linear functions.
20
+ Never see a `for` or an index variable again.
21
+
22
+ It needs Python 3.12+ and PuLP 4. PuLP 4 ships no solver: `pip install "pulp[cbc]"` adds CBC.
23
+
24
+ ## Super Sudoku
25
+
26
+ A Super Sudoku is a Sudoku in which the cells at the same position within their boxes also hold
27
+ distinct digits.
28
+
29
+ ```python
30
+ import numpy as np
31
+ from pulp import LpBinary, LpProblem
32
+
33
+ from pulp_lparray import lparray
34
+
35
+ clues = np.array(
36
+ [
37
+ [0, 4, 0, 0, 1, 8, 0, 0, 0],
38
+ [0, 5, 0, 0, 0, 2, 0, 0, 0],
39
+ [2, 0, 8, 0, 0, 0, 7, 1, 4],
40
+ [1, 8, 2, 0, 0, 0, 5, 6, 0],
41
+ [0, 6, 0, 0, 0, 0, 0, 0, 0],
42
+ [4, 7, 0, 0, 0, 6, 0, 0, 2],
43
+ [0, 0, 5, 0, 9, 7, 0, 0, 1],
44
+ [0, 0, 4, 0, 0, 0, 0, 0, 0],
45
+ [9, 0, 0, 0, 0, 0, 3, 0, 0],
46
+ ]
47
+ ) # 0 marks an empty cell
48
+
49
+ prob = LpProblem("super_sudoku")
50
+ # board[R, C, r, c, n] == 1: the cell in row 3R + r and column 3C + c holds digit n + 1
51
+ board = lparray.create(prob, "board", (3, 3, 3, 3, 9), cat=LpBinary)
52
+ (board.sum(axis=4) == 1).constrain(prob, "cell")
53
+ (board.sum(axis=(1, 3)) == 1).constrain(prob, "row")
54
+ (board.sum(axis=(0, 2)) == 1).constrain(prob, "column")
55
+ (board.sum(axis=(2, 3)) == 1).constrain(prob, "box")
56
+ (board.sum(axis=(0, 1)) == 1).constrain(prob, "position")
57
+ cells = board.transpose(0, 2, 1, 3, 4).reshape(9, 9, 9) # row, column, digit
58
+ row, col = clues.nonzero()
59
+ (cells[row, col, clues[row, col] - 1] == 1).constrain(prob, "clue")
60
+ prob.solve()
61
+
62
+ grid = cells.values.argmax(axis=2) + 1
63
+ assert (grid[row, col] == clues[row, col]).all()
64
+ assert all(sorted(line) == list(range(1, 10)) for line in [*grid, *grid.T])
65
+ ```
66
+
67
+ ## Portfolio allocation
68
+
69
+ Whole numbers of shares whose values best match target weights within a budget, measured by
70
+ relative error so that every position counts alike. Minimising `deviation` subject to
71
+ `deviation >= error` and `deviation >= -error` makes it `|error|` without binaries; `abs` is for
72
+ magnitudes that the objective does not push down.
73
+
74
+ ```python
75
+ from pulp import LpInteger, LpMinimize
76
+
77
+ price = np.array([31.0, 77.0, 12.5, 220.0])
78
+ weight = np.array([0.4, 0.3, 0.2, 0.1])
79
+ funds = 10_000
80
+
81
+ prob = LpProblem("allocation", LpMinimize)
82
+ shares = lparray.create(prob, "shares", 4, lowBound=0, cat=LpInteger)
83
+ (shares @ price <= funds).constrain(prob, "budget")
84
+ target = funds * weight / price
85
+ error = (shares - target) / target
86
+ deviation = lparray.create(prob, "deviation", 4)
87
+ (deviation >= error).constrain(prob, "over")
88
+ (deviation >= -error).constrain(prob, "under")
89
+ prob += deviation.sum().item()
90
+ prob.solve()
91
+ ```
92
+
93
+ ## Facility logistics
94
+
95
+ Open exactly two facilities per region, ship every product to every customer, and pay an
96
+ operating cost with a volume discount on each facility's throughput. The discount makes the cost
97
+ concave, so `lp_interp` selects its segment with binaries.
98
+
99
+ ```python
100
+ region = np.array([0, 0, 0, 1, 1, 1])
101
+ capacity = np.array([12, 6, 6, 12, 6, 6])
102
+ open_cost = np.array([10, 5, 7, 10, 5, 7])
103
+ ship_cost = np.array(
104
+ [
105
+ [2, 2, 3, 9, 9],
106
+ [3, 1, 2, 9, 9],
107
+ [2, 3, 1, 9, 9],
108
+ [9, 9, 9, 2, 2],
109
+ [9, 9, 9, 3, 1],
110
+ [9, 9, 9, 2, 3],
111
+ ]
112
+ ) # facility × customer
113
+ demand = np.array([[4, 2, 1], [0, 3, 2], [1, 1, 0], [3, 0, 1], [2, 2, 2]]) # customer × product
114
+
115
+ prob = LpProblem("logistics", LpMinimize)
116
+ is_open = lparray.create(prob, "open", 6, cat=LpBinary)
117
+ flow = lparray.create(prob, "flow", (6, 5, 3), lowBound=0)
118
+ (flow.sum(axis=0) == demand).constrain(prob, "demand")
119
+ throughput = flow.sum(axis=(1, 2))
120
+ (throughput <= capacity * is_open).constrain(prob, "capacity")
121
+ ((region == np.arange(2)[:, None]) @ is_open == 2).constrain(prob, "two_per_region")
122
+ # The first 5 units cost 3 each, and the rest 2 each.
123
+ operating = throughput.lp_interp(prob, "operating", xp=[0, 5, 12], fp=[0, 15, 29])
124
+ prob += ((ship_cost[:, :, None] * flow).sum() + open_cost @ is_open + operating.sum()).item()
125
+ prob.solve()
126
+
127
+ assert (is_open.values.reshape(2, 3).sum(axis=1) == 2).all()
128
+ ```
129
+
130
+ ## Formulations
131
+
132
+ Methods taking `(prob, name)` add auxiliary variables and constraints named `{name}_{role}`.
133
+
134
+ | method | result |
135
+ | --- | --- |
136
+ | `x.abs(prob, name, bigM=…)` | `\|x\|` |
137
+ | `x.abs_decompose(prob, name, bigM=…)` | `(p, m)`: `p - m == x`, `p, m >= 0`, one of them zero |
138
+ | `x.logical_clip(prob, name, bigM=…)` | binary `min(x, 1)` of integral `x >= 0` |
139
+ | `x.lp_max(prob, name, axis=…, bigM=…)`, `lp_min` | maximum, minimum along `axis` |
140
+ | `x.lp_argmax(prob, name, axis=…, bigM=…)`, `lp_argmin` | one-hot indicator of an extremum along `axis` |
141
+ | `x.lp_topk(prob, name, k=…, axis=…, bigM=…)` | indicator of `k` largest entries along `axis` |
142
+ | `b.lp_bin_max(prob, name, axis=…)`, `lp_bin_min` | logical OR, AND of binary `b` along `axis` |
143
+ | `z.lp_bin_and(prob, name, *bs)`, `lp_bin_or` | constrains binary `z` to the AND, OR of `bs` |
144
+ | `x.lp_interp(prob, name, xp=…, fp=…)` | `np.interp(x, xp, fp)`, breakpoints broadcasting against `x` |
145
+
146
+ `bigM` must bound the spread of the values involved: `|x|` for `abs`, `x` for `logical_clip`,
147
+ and the range along `axis` for the rest. Too small a `bigM` silently cuts off solutions.
148
+ `lp_interp` constrains `x` to lie between the first and last breakpoints rather than clamping it.
149
+
150
+ ## Semantics
151
+
152
+ `constrain(prob, name)` adds each constraint as `name(i,j,…)` and returns the array of
153
+ `LpConstraint`s that the problem stores.
154
+
155
+ Sums, `@` and `dot` take time linear in the contracted length. Other contractions, such as
156
+ `np.einsum` and `np.inner`, run NumPy's object loops, which chain PuLP's `+` and take quadratic
157
+ time. Reductions return 0-d lparrays, whose `item()` is the PuLP expression. Ufuncs that are not
158
+ affine, such as `np.maximum` and `np.abs`, raise `TypeError`; the methods above linearise them.
159
+
160
+ Keep the lparray on the left of `+` and `-` against a single PuLP variable or expression:
161
+ PuLP treats an array on its right as an iterable and sums it into one expression.
162
+
163
+ ## Upgrading from 1.x
164
+
165
+ | 1.x | 2.0 |
166
+ | --- | --- |
167
+ | `lparray.create(name, index_sets)`, `create_anon(name, shape)`, `create_like(name, a)` | `lparray.create(prob, name, shape)` |
168
+ | `x.sumit()` | `x.sum().item()` |
169
+ | `lp_int_max`, `lp_real_max`, `lp_int_min`, `lp_real_min` | `lp_max`, `lp_min` |
170
+ | `lp_bin_argmax`, `lp_bin_argmin`, `lp_bin_topk` | `lp_argmax`, `lp_argmin`, `lp_topk` |
171
+ | `piecewise_linear_sos2` | `lp_interp` |
172
+ | `abs_clip` | `abs` |
173
+ | `one_hot`, `constrain_one_hot`, `all_different`, `indicator_le`, `indicator_ge` | the sum or comparison each wrapped |
174
+ | `soft_select_max`, `count_out` | removed |
175
+
176
+ Every `bigM` is required, and `constrain` returns the stored constraints.
@@ -0,0 +1,165 @@
1
+ # pulp-lparray
2
+
3
+ *PuLP x NumPy, a match made in heaven.*
4
+
5
+ An `lparray` is a NumPy array of [PuLP](https://github.com/coin-or/pulp) variables, affine
6
+ expressions or constraints. Broadcasting, indexing, sums and matrix products build whole
7
+ families of expressions at once; `<=`, `>=` and `==` build families of constraints; and a
8
+ handful of methods linearise abs, max, min, argmax, top-k, logic and piecewise-linear functions.
9
+ Never see a `for` or an index variable again.
10
+
11
+ It needs Python 3.12+ and PuLP 4. PuLP 4 ships no solver: `pip install "pulp[cbc]"` adds CBC.
12
+
13
+ ## Super Sudoku
14
+
15
+ A Super Sudoku is a Sudoku in which the cells at the same position within their boxes also hold
16
+ distinct digits.
17
+
18
+ ```python
19
+ import numpy as np
20
+ from pulp import LpBinary, LpProblem
21
+
22
+ from pulp_lparray import lparray
23
+
24
+ clues = np.array(
25
+ [
26
+ [0, 4, 0, 0, 1, 8, 0, 0, 0],
27
+ [0, 5, 0, 0, 0, 2, 0, 0, 0],
28
+ [2, 0, 8, 0, 0, 0, 7, 1, 4],
29
+ [1, 8, 2, 0, 0, 0, 5, 6, 0],
30
+ [0, 6, 0, 0, 0, 0, 0, 0, 0],
31
+ [4, 7, 0, 0, 0, 6, 0, 0, 2],
32
+ [0, 0, 5, 0, 9, 7, 0, 0, 1],
33
+ [0, 0, 4, 0, 0, 0, 0, 0, 0],
34
+ [9, 0, 0, 0, 0, 0, 3, 0, 0],
35
+ ]
36
+ ) # 0 marks an empty cell
37
+
38
+ prob = LpProblem("super_sudoku")
39
+ # board[R, C, r, c, n] == 1: the cell in row 3R + r and column 3C + c holds digit n + 1
40
+ board = lparray.create(prob, "board", (3, 3, 3, 3, 9), cat=LpBinary)
41
+ (board.sum(axis=4) == 1).constrain(prob, "cell")
42
+ (board.sum(axis=(1, 3)) == 1).constrain(prob, "row")
43
+ (board.sum(axis=(0, 2)) == 1).constrain(prob, "column")
44
+ (board.sum(axis=(2, 3)) == 1).constrain(prob, "box")
45
+ (board.sum(axis=(0, 1)) == 1).constrain(prob, "position")
46
+ cells = board.transpose(0, 2, 1, 3, 4).reshape(9, 9, 9) # row, column, digit
47
+ row, col = clues.nonzero()
48
+ (cells[row, col, clues[row, col] - 1] == 1).constrain(prob, "clue")
49
+ prob.solve()
50
+
51
+ grid = cells.values.argmax(axis=2) + 1
52
+ assert (grid[row, col] == clues[row, col]).all()
53
+ assert all(sorted(line) == list(range(1, 10)) for line in [*grid, *grid.T])
54
+ ```
55
+
56
+ ## Portfolio allocation
57
+
58
+ Whole numbers of shares whose values best match target weights within a budget, measured by
59
+ relative error so that every position counts alike. Minimising `deviation` subject to
60
+ `deviation >= error` and `deviation >= -error` makes it `|error|` without binaries; `abs` is for
61
+ magnitudes that the objective does not push down.
62
+
63
+ ```python
64
+ from pulp import LpInteger, LpMinimize
65
+
66
+ price = np.array([31.0, 77.0, 12.5, 220.0])
67
+ weight = np.array([0.4, 0.3, 0.2, 0.1])
68
+ funds = 10_000
69
+
70
+ prob = LpProblem("allocation", LpMinimize)
71
+ shares = lparray.create(prob, "shares", 4, lowBound=0, cat=LpInteger)
72
+ (shares @ price <= funds).constrain(prob, "budget")
73
+ target = funds * weight / price
74
+ error = (shares - target) / target
75
+ deviation = lparray.create(prob, "deviation", 4)
76
+ (deviation >= error).constrain(prob, "over")
77
+ (deviation >= -error).constrain(prob, "under")
78
+ prob += deviation.sum().item()
79
+ prob.solve()
80
+ ```
81
+
82
+ ## Facility logistics
83
+
84
+ Open exactly two facilities per region, ship every product to every customer, and pay an
85
+ operating cost with a volume discount on each facility's throughput. The discount makes the cost
86
+ concave, so `lp_interp` selects its segment with binaries.
87
+
88
+ ```python
89
+ region = np.array([0, 0, 0, 1, 1, 1])
90
+ capacity = np.array([12, 6, 6, 12, 6, 6])
91
+ open_cost = np.array([10, 5, 7, 10, 5, 7])
92
+ ship_cost = np.array(
93
+ [
94
+ [2, 2, 3, 9, 9],
95
+ [3, 1, 2, 9, 9],
96
+ [2, 3, 1, 9, 9],
97
+ [9, 9, 9, 2, 2],
98
+ [9, 9, 9, 3, 1],
99
+ [9, 9, 9, 2, 3],
100
+ ]
101
+ ) # facility × customer
102
+ demand = np.array([[4, 2, 1], [0, 3, 2], [1, 1, 0], [3, 0, 1], [2, 2, 2]]) # customer × product
103
+
104
+ prob = LpProblem("logistics", LpMinimize)
105
+ is_open = lparray.create(prob, "open", 6, cat=LpBinary)
106
+ flow = lparray.create(prob, "flow", (6, 5, 3), lowBound=0)
107
+ (flow.sum(axis=0) == demand).constrain(prob, "demand")
108
+ throughput = flow.sum(axis=(1, 2))
109
+ (throughput <= capacity * is_open).constrain(prob, "capacity")
110
+ ((region == np.arange(2)[:, None]) @ is_open == 2).constrain(prob, "two_per_region")
111
+ # The first 5 units cost 3 each, and the rest 2 each.
112
+ operating = throughput.lp_interp(prob, "operating", xp=[0, 5, 12], fp=[0, 15, 29])
113
+ prob += ((ship_cost[:, :, None] * flow).sum() + open_cost @ is_open + operating.sum()).item()
114
+ prob.solve()
115
+
116
+ assert (is_open.values.reshape(2, 3).sum(axis=1) == 2).all()
117
+ ```
118
+
119
+ ## Formulations
120
+
121
+ Methods taking `(prob, name)` add auxiliary variables and constraints named `{name}_{role}`.
122
+
123
+ | method | result |
124
+ | --- | --- |
125
+ | `x.abs(prob, name, bigM=…)` | `\|x\|` |
126
+ | `x.abs_decompose(prob, name, bigM=…)` | `(p, m)`: `p - m == x`, `p, m >= 0`, one of them zero |
127
+ | `x.logical_clip(prob, name, bigM=…)` | binary `min(x, 1)` of integral `x >= 0` |
128
+ | `x.lp_max(prob, name, axis=…, bigM=…)`, `lp_min` | maximum, minimum along `axis` |
129
+ | `x.lp_argmax(prob, name, axis=…, bigM=…)`, `lp_argmin` | one-hot indicator of an extremum along `axis` |
130
+ | `x.lp_topk(prob, name, k=…, axis=…, bigM=…)` | indicator of `k` largest entries along `axis` |
131
+ | `b.lp_bin_max(prob, name, axis=…)`, `lp_bin_min` | logical OR, AND of binary `b` along `axis` |
132
+ | `z.lp_bin_and(prob, name, *bs)`, `lp_bin_or` | constrains binary `z` to the AND, OR of `bs` |
133
+ | `x.lp_interp(prob, name, xp=…, fp=…)` | `np.interp(x, xp, fp)`, breakpoints broadcasting against `x` |
134
+
135
+ `bigM` must bound the spread of the values involved: `|x|` for `abs`, `x` for `logical_clip`,
136
+ and the range along `axis` for the rest. Too small a `bigM` silently cuts off solutions.
137
+ `lp_interp` constrains `x` to lie between the first and last breakpoints rather than clamping it.
138
+
139
+ ## Semantics
140
+
141
+ `constrain(prob, name)` adds each constraint as `name(i,j,…)` and returns the array of
142
+ `LpConstraint`s that the problem stores.
143
+
144
+ Sums, `@` and `dot` take time linear in the contracted length. Other contractions, such as
145
+ `np.einsum` and `np.inner`, run NumPy's object loops, which chain PuLP's `+` and take quadratic
146
+ time. Reductions return 0-d lparrays, whose `item()` is the PuLP expression. Ufuncs that are not
147
+ affine, such as `np.maximum` and `np.abs`, raise `TypeError`; the methods above linearise them.
148
+
149
+ Keep the lparray on the left of `+` and `-` against a single PuLP variable or expression:
150
+ PuLP treats an array on its right as an iterable and sums it into one expression.
151
+
152
+ ## Upgrading from 1.x
153
+
154
+ | 1.x | 2.0 |
155
+ | --- | --- |
156
+ | `lparray.create(name, index_sets)`, `create_anon(name, shape)`, `create_like(name, a)` | `lparray.create(prob, name, shape)` |
157
+ | `x.sumit()` | `x.sum().item()` |
158
+ | `lp_int_max`, `lp_real_max`, `lp_int_min`, `lp_real_min` | `lp_max`, `lp_min` |
159
+ | `lp_bin_argmax`, `lp_bin_argmin`, `lp_bin_topk` | `lp_argmax`, `lp_argmin`, `lp_topk` |
160
+ | `piecewise_linear_sos2` | `lp_interp` |
161
+ | `abs_clip` | `abs` |
162
+ | `one_hot`, `constrain_one_hot`, `all_different`, `indicator_le`, `indicator_ge` | the sum or comparison each wrapped |
163
+ | `soft_select_max`, `count_out` | removed |
164
+
165
+ Every `bigM` is required, and `constrain` returns the stored constraints.
@@ -0,0 +1,3 @@
1
+ from .lparray import lparray
2
+
3
+ __all__ = ["lparray"]
@@ -0,0 +1,373 @@
1
+ """NumPy arrays of PuLP variables, affine expressions and constraints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import TYPE_CHECKING, Any, Literal, Self
7
+
8
+ import numpy as np
9
+ from numpy.lib.array_utils import normalize_axis_tuple
10
+ from pulp import (
11
+ LpAffineExpression,
12
+ LpBinary,
13
+ LpConstraint,
14
+ LpProblem,
15
+ LpVariable,
16
+ lpSum,
17
+ value,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable, Iterable, Mapping
22
+
23
+ from numpy.typing import ArrayLike, NDArray
24
+
25
+ type Category = Literal["Continuous", "Integer", "Binary"]
26
+ type Axis = int | tuple[int, ...] | None
27
+
28
+
29
+ class lparray(np.ndarray[tuple[int, ...], np.dtype[np.object_]]):
30
+ """An object ndarray of PuLP variables, affine expressions or constraints.
31
+
32
+ Arithmetic, indexing and broadcasting are NumPy's, applied elementwise, and NumPy functions
33
+ return lparrays; ufuncs that are not affine raise TypeError. `<=`, `>=` and `==` build arrays
34
+ of constraints, which `constrain` adds to a problem. Sums, ``@ and `dot` take time linear in
35
+ the contracted length. Reductions return 0-d lparrays, whose `item()` is the PuLP scalar.
36
+
37
+ Methods taking `(prob, name)` linearise nonlinear operations with auxiliary variables and
38
+ constraints named `{name}_{role}`. Those taking `bigM` require it to bound the spread of the
39
+ values involved; too small a bigM silently cuts off feasible solutions.
40
+
41
+ Keep the lparray on the left of `+` and `-` against a scalar PuLP operand: PuLP treats a
42
+ right-hand array as an iterable and sums it into a single expression.
43
+ """
44
+
45
+ __hash__ = None
46
+
47
+ # PuLP 4 scalars set __array_priority__ = 20, so a lower priority makes ndarray operators
48
+ # defer to the scalar's reflected method, which sums the whole array into one expression.
49
+ __array_priority__ = math.inf
50
+
51
+ if TYPE_CHECKING:
52
+ # NumPy's stubs type these as plain or boolean ndarrays; __array_ufunc__ returns lparrays.
53
+ def __getitem__(self, key: object, /) -> Any: ... # noqa: ANN401
54
+ def __neg__(self) -> lparray: ...
55
+ def __add__(self, other: object, /) -> lparray: ...
56
+ def __radd__(self, other: ArrayLike, /) -> lparray: ...
57
+ def __sub__(self, other: object, /) -> lparray: ...
58
+ def __rsub__(self, other: ArrayLike, /) -> lparray: ...
59
+ def __mul__(self, other: object, /) -> lparray: ...
60
+ def __rmul__(self, other: ArrayLike, /) -> lparray: ...
61
+ def __truediv__(self, other: object, /) -> lparray: ...
62
+ def __matmul__(self, other: object, /) -> lparray: ...
63
+ def __rmatmul__(self, other: ArrayLike, /) -> lparray: ...
64
+ # Deliberately incompatible: comparisons build constraints, and the rest keep the subclass.
65
+ def __le__(self, other: object, /) -> lparray: ... # ty: ignore[invalid-method-override]
66
+ def __ge__(self, other: object, /) -> lparray: ... # ty: ignore[invalid-method-override]
67
+ def __eq__(self, other: object, /) -> lparray: ... # ty: ignore[invalid-method-override]
68
+ def sum(self, axis: Axis = None, *, keepdims: bool = False) -> lparray: ... # ty: ignore[invalid-method-override]
69
+ def reshape(self, *shape: Any, **kwargs: Any) -> lparray: ... # ty: ignore[invalid-method-override]
70
+ def ravel(self, order: Literal["K", "A", "C", "F"] = "C") -> lparray: ... # ty: ignore[invalid-method-override]
71
+ def flatten(self, order: Literal["K", "A", "C", "F"] = "C") -> lparray: ... # ty: ignore[invalid-method-override]
72
+ def squeeze(self, axis: Axis = None) -> lparray: ... # ty: ignore[invalid-method-override]
73
+
74
+ @classmethod
75
+ def create(
76
+ cls,
77
+ prob: LpProblem,
78
+ name: str,
79
+ shape: int | tuple[int, ...],
80
+ *,
81
+ lowBound: float | None = None,
82
+ upBound: float | None = None,
83
+ cat: Category = "Continuous",
84
+ ) -> Self:
85
+ """Variables of `prob` named `name(i,j,…)` by their indices."""
86
+ arr = np.empty(shape, dtype=object)
87
+ for ix in np.ndindex(arr.shape):
88
+ arr[ix] = prob.add_variable(_subscript(name, ix), lowBound, upBound, cat)
89
+ return arr.view(cls)
90
+
91
+ def __array_ufunc__(self, ufunc: np.ufunc, method: str, *inputs: Any, **kwargs: Any) -> object:
92
+ args = [x.view(np.ndarray) if isinstance(x, lparray) else _shield(x) for x in inputs]
93
+ if out := kwargs.get("out"):
94
+ kwargs["out"] = tuple(o.view(np.ndarray) for o in out)
95
+ match ufunc, method:
96
+ case np.add, "reduce":
97
+ result = _lpsum(*args, **kwargs)
98
+ case np.matmul, "__call__":
99
+ result = _matmul(*args, **kwargs)
100
+ case np.less_equal | np.greater_equal | np.equal, "__call__":
101
+ # PuLP 4 compares two variables by identity, yielding a bool. A difference is
102
+ # always an expression, and comparing an expression with 0 is a constraint.
103
+ difference = _shield(np.subtract(*args, dtype=object))
104
+ result = ufunc(difference, 0, **kwargs | {"dtype": object})
105
+ case np.add | np.subtract | np.multiply | np.true_divide | np.negative | np.positive, _:
106
+ result = getattr(ufunc, method)(*args, **kwargs)
107
+ case _:
108
+ raise TypeError(
109
+ f"lparray supports affine ufuncs, <=, >= and ==, not np.{ufunc.__name__}."
110
+ f"{method}; lp_max, lp_min and abs linearise extrema and magnitudes"
111
+ )
112
+ if out:
113
+ return out[0] if len(out) == 1 else out
114
+ return _wrap(result)
115
+
116
+ def __array_function__(
117
+ self,
118
+ func: Callable[..., Any],
119
+ types: Iterable[type],
120
+ args: Iterable[Any],
121
+ kwargs: Mapping[str, Any],
122
+ ) -> object:
123
+ args = tuple(map(_shield, args))
124
+ kwargs = {key: _shield(arg) for key, arg in kwargs.items()}
125
+ if func is np.dot:
126
+ return _wrap(_dot(*args, **kwargs))
127
+ return _wrap(super().__array_function__(func, types, args, kwargs))
128
+
129
+ def dot(self, b: ArrayLike, /) -> lparray: # ty: ignore[invalid-method-override]
130
+ """`np.dot(self, b)`, which ndarray.dot would compute without dispatching to lparray."""
131
+ return np.dot(self, b)
132
+
133
+ if not TYPE_CHECKING: # NumPy's stubs type assignment as it is
134
+
135
+ def __setitem__(self, key: object, value: object, /) -> None:
136
+ super().__setitem__(key, _shield(value))
137
+
138
+ @property
139
+ def values(self) -> NDArray[np.float64]:
140
+ """The solved value of each element."""
141
+ return np.fromiter(map(value, self.flat), np.float64, self.size).reshape(self.shape)
142
+
143
+ def constrain(self, prob: LpProblem, name: str) -> lparray:
144
+ """Add each constraint to `prob` as `name(i,j,…)`; return the stored constraints."""
145
+ stored = np.empty(self.shape, dtype=object)
146
+ for ix in np.ndindex(self.shape):
147
+ if not isinstance(c := self[ix], LpAffineExpression):
148
+ raise TypeError(f"{_subscript(name, ix)}: {c!r} is not a constraint")
149
+ stored[ix] = prob.addConstraint(c, _subscript(name, ix))
150
+ return stored.view(lparray)
151
+
152
+ def abs_decompose(self, prob: LpProblem, name: str, *, bigM: float) -> tuple[lparray, lparray]:
153
+ """The positive and negative parts `(p, m)` of self: `p - m == self`, `p, m >= 0`,
154
+ and at most one of them nonzero. Requires `|self| <= bigM`."""
155
+ neg = lparray.create(prob, f"{name}_neg", self.shape, cat=LpBinary)
156
+ p = lparray.create(prob, f"{name}_p", self.shape, lowBound=0)
157
+ m = lparray.create(prob, f"{name}_m", self.shape, lowBound=0)
158
+ (p - m == self).constrain(prob, f"{name}_split")
159
+ (p <= bigM * (1 - neg)).constrain(prob, f"{name}_p_off")
160
+ (m <= bigM * neg).constrain(prob, f"{name}_m_off")
161
+ return p, m
162
+
163
+ def abs(self, prob: LpProblem, name: str, *, bigM: float) -> lparray:
164
+ """`|self|`; requires `|self| <= bigM`."""
165
+ p, m = self.abs_decompose(prob, name, bigM=bigM)
166
+ return p + m
167
+
168
+ def logical_clip(self, prob: LpProblem, name: str, *, bigM: float) -> lparray:
169
+ """Binary `min(self, 1)` of integral `0 <= self <= bigM`."""
170
+ z = lparray.create(prob, f"{name}_z", self.shape, cat=LpBinary)
171
+ (self >= z).constrain(prob, f"{name}_lb")
172
+ (self <= bigM * z).constrain(prob, f"{name}_ub")
173
+ return z
174
+
175
+ def lp_max(self, prob: LpProblem, name: str, *, axis: Axis = None, bigM: float) -> lparray:
176
+ """Maximum along `axis`; requires the spread of self along it to be at most bigM."""
177
+ return self._extremum(prob, name, axis, bigM, 1)[0]
178
+
179
+ def lp_min(self, prob: LpProblem, name: str, *, axis: Axis = None, bigM: float) -> lparray:
180
+ """Minimum along `axis`; requires the spread of self along it to be at most bigM."""
181
+ return self._extremum(prob, name, axis, bigM, -1)[0]
182
+
183
+ def lp_argmax(self, prob: LpProblem, name: str, *, axis: Axis = None, bigM: float) -> lparray:
184
+ """Binary indicator, one-hot along `axis`, of an entry attaining the maximum."""
185
+ return self._extremum(prob, name, axis, bigM, 1)[1]
186
+
187
+ def lp_argmin(self, prob: LpProblem, name: str, *, axis: Axis = None, bigM: float) -> lparray:
188
+ """Binary indicator, one-hot along `axis`, of an entry attaining the minimum."""
189
+ return self._extremum(prob, name, axis, bigM, -1)[1]
190
+
191
+ def lp_bin_max(self, prob: LpProblem, name: str, *, axis: Axis = None) -> lparray:
192
+ """Logical OR of binary self along `axis`."""
193
+ axes, kept = self._reduction(axis)
194
+ z = lparray.create(prob, f"{name}_z", kept, lowBound=0, upBound=1)
195
+ _all(prob, name, 1 - z, 1 - self, axes)
196
+ return z
197
+
198
+ def lp_bin_min(self, prob: LpProblem, name: str, *, axis: Axis = None) -> lparray:
199
+ """Logical AND of binary self along `axis`."""
200
+ axes, kept = self._reduction(axis)
201
+ z = lparray.create(prob, f"{name}_z", kept, lowBound=0, upBound=1)
202
+ _all(prob, name, z, self, axes)
203
+ return z
204
+
205
+ def lp_bin_and(self, prob: LpProblem, name: str, *ins: ArrayLike) -> Self:
206
+ """Constrain binary self to the elementwise logical AND of `ins`; return self."""
207
+ _all(prob, name, self, _stacked(ins, self.shape), (0,))
208
+ return self
209
+
210
+ def lp_bin_or(self, prob: LpProblem, name: str, *ins: ArrayLike) -> Self:
211
+ """Constrain binary self to the elementwise logical OR of `ins`; return self."""
212
+ _all(prob, name, 1 - self, 1 - _stacked(ins, self.shape), (0,))
213
+ return self
214
+
215
+ def lp_topk(
216
+ self, prob: LpProblem, name: str, *, k: int, axis: Axis = None, bigM: float
217
+ ) -> lparray:
218
+ """Binary indicator of `k` entries along `axis`, none smaller than an unselected one."""
219
+ axes, kept = self._reduction(axis)
220
+ if not 0 <= k <= math.prod(self.shape[d] for d in axes):
221
+ raise ValueError(f"{name}: cannot select {k} of {self.shape} along {axes}")
222
+ threshold = lparray.create(prob, f"{name}_t", kept)
223
+ w = lparray.create(prob, f"{name}_w", self.shape, cat=LpBinary)
224
+ (w.sum(axis=axes) == k).constrain(prob, f"{name}_k")
225
+ gap = self - _expand(threshold, axes)
226
+ (gap >= -bigM * (1 - w)).constrain(prob, f"{name}_in")
227
+ (gap <= bigM * w).constrain(prob, f"{name}_out")
228
+ return w
229
+
230
+ def lp_interp(self, prob: LpProblem, name: str, *, xp: ArrayLike, fp: ArrayLike) -> lparray:
231
+ """`np.interp(self, xp, fp)`, constraining self to `[xp[..., 0], xp[..., -1]]`.
232
+
233
+ The breakpoints `xp` and values `fp` lie along their last axis; their leading axes
234
+ broadcast against self."""
235
+ xs, fs = np.broadcast_arrays(np.asarray(xp, dtype=float), np.asarray(fp, dtype=float))
236
+ if xs.ndim == 0 or xs.shape[-1] < 2 or np.any(np.diff(xs) <= 0):
237
+ raise ValueError("xp must increase strictly along its last axis, of length >= 2")
238
+ shape = np.broadcast_shapes(self.shape, xs.shape[:-1])
239
+ weight = lparray.create(prob, f"{name}_weight", (*shape, xs.shape[-1]), lowBound=0)
240
+ segment = lparray.create(prob, f"{name}_segment", (*shape, xs.shape[-1] - 1), cat=LpBinary)
241
+ (weight.sum(axis=-1) == 1).constrain(prob, f"{name}_weight_sum")
242
+ (segment.sum(axis=-1) == 1).constrain(prob, f"{name}_segment_sum")
243
+ # A weight may be positive only beside the chosen segment: SOS2 adjacency.
244
+ edge = np.zeros((*shape, 1), dtype=object)
245
+ flanks = np.concatenate([edge, segment, edge], axis=-1)
246
+ (weight <= flanks[..., :-1] + flanks[..., 1:]).constrain(prob, f"{name}_adjacent")
247
+ (self == (weight * xs).sum(axis=-1)).constrain(prob, f"{name}_x")
248
+ return (weight * fs).sum(axis=-1)
249
+
250
+ def _extremum(
251
+ self, prob: LpProblem, name: str, axis: Axis, bigM: float, sign: Literal[1, -1]
252
+ ) -> tuple[lparray, lparray]:
253
+ """`(t, w)`: `sign·t = max(sign·self)` along `axis`, `w` one-hot where attained."""
254
+ axes, kept = self._reduction(axis)
255
+ if not math.prod(self.shape[d] for d in axes):
256
+ raise ValueError(f"no extremum of an empty slice of {self.shape} along {axes}")
257
+ t = lparray.create(prob, f"{name}_t", kept)
258
+ w = lparray.create(prob, f"{name}_w", self.shape, cat=LpBinary)
259
+ (w.sum(axis=axes) == 1).constrain(prob, f"{name}_one")
260
+ gap = sign * (_expand(t, axes) - self)
261
+ (gap >= 0).constrain(prob, f"{name}_bound")
262
+ (gap <= bigM * (1 - w)).constrain(prob, f"{name}_attain")
263
+ return t, w
264
+
265
+ def _reduction(self, axis: Axis) -> tuple[tuple[int, ...], tuple[int, ...]]:
266
+ """The axes reduced over and the shape left."""
267
+ axes = normalize_axis_tuple(range(self.ndim) if axis is None else axis, self.ndim)
268
+ return axes, tuple(n for d, n in enumerate(self.shape) if d not in axes)
269
+
270
+
271
+ def _subscript(name: str, ix: tuple[int, ...]) -> str:
272
+ return f"{name}({','.join(map(str, ix))})" if ix else name
273
+
274
+
275
+ def _box(x: object) -> NDArray[np.object_]:
276
+ box = np.empty((), dtype=object)
277
+ box[()] = x
278
+ return box
279
+
280
+
281
+ def _shield[T](x: T) -> T | NDArray[np.object_]:
282
+ """Box a PuLP 4 expression or constraint in a 0-d array, and those within lists and tuples:
283
+ both define __len__ and __getitem__, so NumPy would read one as the sequence of its
284
+ variables."""
285
+ match x:
286
+ case LpAffineExpression() | LpConstraint():
287
+ return _box(x)
288
+ case list() | tuple() if type(x) in (list, tuple):
289
+ return type(x)(map(_shield, x))
290
+ case _:
291
+ return x
292
+
293
+
294
+ def _wrap(x: object) -> object:
295
+ """View the object ndarrays among a NumPy result as lparrays, and box the PuLP scalars that
296
+ NumPy returns for 0-d results into 0-d lparrays."""
297
+ match x:
298
+ case np.ndarray(dtype=np.object_):
299
+ return x.view(lparray)
300
+ case LpAffineExpression() | LpVariable() | LpConstraint():
301
+ return _box(x).view(lparray)
302
+ case tuple():
303
+ return tuple(map(_wrap, x))
304
+ case list():
305
+ return list(map(_wrap, x))
306
+ case _:
307
+ return x
308
+
309
+
310
+ def _lpsum(
311
+ a: NDArray[np.object_],
312
+ *,
313
+ axis: Axis = 0,
314
+ dtype: object = None,
315
+ keepdims: bool = False,
316
+ where: object = True,
317
+ ) -> NDArray[np.object_]:
318
+ """`np.add.reduce` through `lpSum`. NumPy's object reduction chains `+`, and each PuLP `+`
319
+ copies its left operand, so it takes time quadratic in the length reduced."""
320
+ if dtype not in (None, object) or where is not True:
321
+ raise TypeError("lparray sums take only axis and keepdims")
322
+ if a.ndim == 0 and axis in (0, -1):
323
+ axis = () # NumPy reduces a 0-d array along axis 0 to itself
324
+ axes = normalize_axis_tuple(range(a.ndim) if axis is None else axis, a.ndim)
325
+ kept = [d for d in range(a.ndim) if d not in axes]
326
+ rows = a.transpose(*kept, *axes).reshape(
327
+ *(a.shape[d] for d in kept), math.prod(a.shape[d] for d in axes)
328
+ )
329
+ out = np.empty(rows.shape[:-1], dtype=object)
330
+ for ix in np.ndindex(out.shape):
331
+ out[ix] = lpSum(rows[ix])
332
+ return np.expand_dims(out, axes) if keepdims else out
333
+
334
+
335
+ def _matmul(a: ArrayLike, b: ArrayLike) -> NDArray[np.object_]:
336
+ """`a @ b` as a sum of broadcast products, in time linear in the contracted length."""
337
+ lhs, rhs = np.asarray(a, dtype=object), np.asarray(b, dtype=object)
338
+ if not lhs.ndim or not rhs.ndim or lhs.shape[-1] != rhs.shape[-2 if rhs.ndim > 1 else 0]:
339
+ raise ValueError(f"matmul cannot contract {lhs.shape} with {rhs.shape}")
340
+ products = (lhs[None] if lhs.ndim == 1 else lhs)[..., :, :, None] * (
341
+ rhs[:, None] if rhs.ndim == 1 else rhs
342
+ )[..., None, :, :]
343
+ promoted = (-2,) * (lhs.ndim == 1) + (-1,) * (rhs.ndim == 1)
344
+ return _lpsum(products, axis=-2).squeeze(axis=promoted)
345
+
346
+
347
+ def _dot(a: ArrayLike, b: ArrayLike) -> object:
348
+ """`np.dot(a, b)` through `_matmul`, which agrees with it when b has at most two axes."""
349
+ lhs, rhs = np.asarray(a, dtype=object), np.asarray(b, dtype=object)
350
+ if not lhs.ndim or not rhs.ndim:
351
+ return np.multiply(lhs, rhs)
352
+ if rhs.ndim <= 2:
353
+ return _matmul(lhs, rhs)
354
+ # dot contracts the last axis of a with the second-to-last of b; b's other axes follow a's.
355
+ flat = np.moveaxis(rhs, -2, 0).reshape(rhs.shape[-2], -1)
356
+ return _matmul(lhs, flat).reshape(*lhs.shape[:-1], *rhs.shape[:-2], rhs.shape[-1])
357
+
358
+
359
+ def _expand(t: lparray, axes: tuple[int, ...]) -> lparray:
360
+ """A reduction's result with unit axes restored, to broadcast against its operand."""
361
+ return np.expand_dims(t, axes).view(lparray)
362
+
363
+
364
+ def _stacked(ins: tuple[ArrayLike, ...], shape: tuple[int, ...]) -> NDArray[Any]:
365
+ """`ins` broadcast to `shape` and stacked along a new first axis."""
366
+ return np.stack([np.broadcast_to(_shield(x), shape, subok=True) for x in ins])
367
+
368
+
369
+ def _all(prob: LpProblem, name: str, z: lparray, x: NDArray[Any], axes: tuple[int, ...]) -> None:
370
+ """Constrain `0 <= z <= 1` to the logical AND of binary `x` along `axes`."""
371
+ n = math.prod(x.shape[d] for d in axes)
372
+ (_expand(z, axes) <= x).constrain(prob, f"{name}_each")
373
+ (z >= x.sum(axis=axes) - (n - 1)).constrain(prob, f"{name}_sum")
File without changes
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pulp-lparray"
7
+ version = "2.0.0"
8
+ description = "PuLP x NumPy"
9
+ readme = "README.md"
10
+ license = "MIT-0"
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "numpy>=2",
14
+ "pulp>=4",
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pulp[cbc]",
20
+ "pytest",
21
+ "ruff",
22
+ "ty",
23
+ ]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+ addopts = "--import-mode=importlib --strict-config --strict-markers"
28
+ filterwarnings = ["error"]
29
+
30
+ [tool.hatch.build]
31
+ include = ["pulp_lparray"]
32
+
33
+ [tool.ruff]
34
+ line-length = 100
35
+
36
+ [tool.ruff.lint]
37
+ select = ["ALL"]
38
+ ignore = [
39
+ "D", # docstrings (intentionally not enforced)
40
+ "TRY", # try/except micro-style (intentionally not enforced)
41
+ "COM", # trailing commas (formatter handles)
42
+ "EM", # exception message micro-style
43
+ "N", # PuLP naming (lowBound, upBound, bigM) and the lowercase lparray class
44
+ "PLR0913",# keep high-arity internal helpers
45
+ "PLR2004",# magic numbers are fine
46
+ "TC", # type-checking blocks
47
+ "CPY", # no per-file copyright headers
48
+ ]
49
+
50
+ [tool.ruff.format]
51
+ quote-style = "preserve"
52
+
53
+ [tool.ruff.lint.flake8-annotations]
54
+ allow-star-arg-any = true # forwarding NumPy's dynamically typed dispatch arguments
55
+
56
+ [tool.ruff.lint.per-file-ignores]
57
+ "tests/**" = ["INP001", "S101"]
58
+
59
+ [tool.ty.terminal]
60
+ error-on-warning = true