ndplex 0.1.0__py3-none-any.whl

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.
ndplex/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """
2
+ ndplex -- the n-Dimensional m-Plex array: a generic Pascal's array (rectangle,
3
+ cube or higher) with user-defined edges and node-generating function.
4
+
5
+ from ndplex import ndplex, oeis
6
+
7
+ p = ndplex(9) # Pascal's rectangle, 9 x 9
8
+ p.diag(loc=(4, 0), move=(-1, 1)) # row 4 of Pascal's triangle
9
+ p.as_tri() # the whole triangle, justified left
10
+ oeis(p.as_tri(), trim=True, size=5) # look the rows up on oeis.org
11
+
12
+ `import ndplex` needs only numpy. `oeis` is loaded on first use and needs
13
+ network access when called.
14
+
15
+ Attribution (see ATTRIBUTION.md for the tiered breakdown and references):
16
+ - Original work (2019-2024): Ted Teske -- the ndplex array, diag, as_tri
17
+ and oeis, in the notebook ndplex_array.ipynb.
18
+ - Packaging and the Phase 2 fixes (2026): Ted Teske with Claude (Anthropic).
19
+ """
20
+ from ._core import ndplex
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = ["ndplex", "oeis", "__version__"]
25
+
26
+ _MOVED = (
27
+ "is not part of the ndplex package (from 0.1.0, plotting is left to the "
28
+ "user). The notebook's version is in ndplex_array.ipynb, in the "
29
+ "repository: https://github.com/tedteske/ndplex")
30
+
31
+
32
+ def __getattr__(name):
33
+ if name == "oeis":
34
+ from ._oeis import oeis
35
+ return oeis
36
+ if name in ("plot_ndplex", "diag_as_hist"):
37
+ raise ImportError(f"{name} {_MOVED}")
38
+ raise AttributeError(f"module 'ndplex' has no attribute {name!r}")
39
+
40
+
41
+ def __dir__():
42
+ return sorted(set(globals()) | {"oeis"})
ndplex/_core.py ADDED
@@ -0,0 +1,399 @@
1
+ """
2
+ The ndplex array: a generic Pascal's array of any dimension.
3
+
4
+ Original design and code: Ted Teske, 2019-2024 (the notebook
5
+ ndplex_array.ipynb). Package version: Ted Teske with Claude (Anthropic),
6
+ 2026; the changes from the notebook are listed in docs/phase2_package.md.
7
+ """
8
+ import itertools as it
9
+ import operator
10
+ import pickle
11
+ import warnings
12
+
13
+ import numpy as np
14
+
15
+ __all__ = ["ndplex"]
16
+
17
+ # Padding and fill values below are created with np.zeros or assigned as the
18
+ # Python int 0. For dtype=object both give Python int zeros, so sums of
19
+ # object arrays stay exact (the notebook padded with np.pad, which inserts
20
+ # numpy int64 zeros: audit finding N12).
21
+
22
+
23
+ def _trim_outside(values, inside):
24
+ """Trim trailing positions that lie wholly outside the array.
25
+
26
+ `inside` has the shape of `values` and is True where a value was read
27
+ from the array (False where it is zero padding). Along each axis, the
28
+ result ends at the last position with any value read from the array,
29
+ so genuine zeros are kept and an all-zero line returns zeros.
30
+ """
31
+ slices = []
32
+ for axis in range(values.ndim):
33
+ others = tuple(a for a in range(values.ndim) if a != axis)
34
+ hits = np.flatnonzero(inside.any(axis=others))
35
+ slices.append(slice(hits[-1] + 1 if hits.size else 0))
36
+ return values[tuple(slices)]
37
+
38
+
39
+ class ndplex(np.ndarray):
40
+ """
41
+ n-Dimensional m-Plex (ndplex) array: a generic Pascal's rectangle, cube,
42
+ or higher-dimensional array, as a numpy array subclass.
43
+
44
+ By default, a square array of integers with ones on the top and left
45
+ edges and every interior node generated by Pascal's rule (the sum of the
46
+ node above and the node to the left). Any number of edges, any edge
47
+ values and any node-generating function may be given instead.
48
+
49
+ Parameters
50
+ ----------
51
+ nrows : int >= 1
52
+ Length of each edge; the array has shape (nrows,) * ndim.
53
+ edges : int, or sequence of numbers or 1-D sequences of length nrows
54
+ The front edges. An int n means n edges of ones. The number of edges
55
+ sets ndim. Edge k lies along axis k (all other coordinates 0): in
56
+ 2-D, edges[0] fills column 0 and edges[1] fills row 0. Where edges
57
+ meet, at the origin, the last edge's value is kept.
58
+ arg_locs : None or sequence of sequences of ints <= 0 or None
59
+ Locations, relative to the node being generated, of the arguments
60
+ passed to `func`, in that order. None (the default) means the
61
+ neighbour one step back along each axis: [[-1, 0], [0, -1]] in 2-D.
62
+ A None coordinate means "on the edge" (coordinate 0 on that axis).
63
+ A location outside the array reads 0. Positive offsets are not
64
+ allowed (those nodes have not been computed yet).
65
+ func : callable
66
+ Called with the list of argument values for each interior node;
67
+ returns the node's value. Default: the builtin `sum`.
68
+ dtype : numpy dtype
69
+ Default np.int64 on every platform. int64 is exact only while every
70
+ node fits: the default 2-D array is exact for i + j <= 66 and the
71
+ default 3-D array for i + j + k <= 43. If any node overflows (in
72
+ func's arithmetic, or because func returns an integer that does not
73
+ fit dtype), one RuntimeWarning names the first node that did; values
74
+ are kept as numpy computes them (wrapped modulo 2**64, or 2**bits
75
+ for a narrower integer dtype). The same holds on every platform and
76
+ NumPy version. Use dtype=object for
77
+ exact integers of any size, or reduce inside `func` (for example a
78
+ modulus) when only a residue is needed.
79
+
80
+ Attributes
81
+ ----------
82
+ nrows : int or None
83
+ Length of each edge, read from the shape (None if the sides differ,
84
+ as for some slices).
85
+ edges, func, arg_locs : the parameters the array was built with,
86
+ carried along by slices, copies and arithmetic as a record; they are
87
+ not used after construction.
88
+
89
+ Methods
90
+ -------
91
+ diag(loc, move, sum_axis)
92
+ A line or cross-section through the array.
93
+ as_tri(start, end, sums)
94
+ The array as a triangle (2-D), tetrahedron (3-D) or n-simplex.
95
+
96
+ Notes
97
+ -----
98
+ Slices and arithmetic return ndplex arrays; reductions such as `sum`
99
+ and `max` return plain numpy scalars; `diag` and `as_tri` return plain
100
+ numpy arrays. Pickling keeps the attributes, except a `func` that cannot
101
+ itself be pickled (such as a lambda), which comes back as None.
102
+ """
103
+
104
+ def __new__(cls, nrows, edges=(1, 1), arg_locs=None, func=sum,
105
+ dtype=np.int64):
106
+ if nrows < 1:
107
+ raise ValueError(f"nrows must be at least 1, got {nrows}")
108
+
109
+ if isinstance(edges, (int, np.integer)):
110
+ if edges < 1:
111
+ raise ValueError(f"edges must be at least 1, got {edges}")
112
+ edges = (1,) * int(edges)
113
+ ndim = len(edges)
114
+ if ndim < 1:
115
+ raise ValueError("edges must not be empty")
116
+ for k, e in enumerate(edges):
117
+ shape_e = np.shape(e)
118
+ if not (shape_e == () or shape_e == (nrows,)):
119
+ raise ValueError(
120
+ f"edge {k} must be a number or a 1-D sequence of length "
121
+ f"nrows={nrows}; got shape {shape_e}")
122
+
123
+ base = np.zeros((nrows,) * ndim, dtype=dtype)
124
+
125
+ # Populate the front edges: rotate the axes so that each edge in turn
126
+ # lies along the last axis, and fill it. After ndim rotations the
127
+ # axes are back in their original order.
128
+ view = base
129
+ edge_loc = (0,) * (ndim - 1) + (slice(None),)
130
+ for k in range(ndim):
131
+ view = np.moveaxis(view, 0, -1)
132
+ view[edge_loc] = edges[k]
133
+
134
+ if arg_locs is None or len(arg_locs) == 0:
135
+ arg_locs = -np.eye(ndim, dtype=int)
136
+ offsets = [tuple(int(v) for v in row) for row in arg_locs]
137
+ else:
138
+ offsets = []
139
+ for loc in arg_locs:
140
+ if len(loc) != ndim:
141
+ raise ValueError(
142
+ f"each arg_loc needs {ndim} coordinates, got "
143
+ f"{tuple(loc)}")
144
+ offset = []
145
+ for v in loc:
146
+ if v is not None:
147
+ v = operator.index(v)
148
+ if v > 0:
149
+ raise ValueError(
150
+ f"arg_locs offsets must be <= 0 (or None), "
151
+ f"got {tuple(loc)}")
152
+ offset.append(v)
153
+ offsets.append(tuple(offset))
154
+
155
+ zero = base.dtype.type(0) # for dtype=object, the Python int 0
156
+ # Integer dtypes: also check that each integer func returns fits the
157
+ # dtype before it is stored, and wrap it if not. numpy's own overflow
158
+ # check (errstate, below) only sees overflow inside func's
159
+ # arithmetic, and numpy handles a value too wide for the dtype
160
+ # differently by version: NumPy < 2 wraps it silently on assignment,
161
+ # NumPy 2 raises OverflowError. The wide value is what the default
162
+ # func=sum returns on NumPy < 2 for dtypes narrower than the
163
+ # platform's default int (int8/16/32 on Linux and macOS), because
164
+ # sum's start value 0 promotes the total to that default int.
165
+ if np.issubdtype(base.dtype, np.integer):
166
+ info = np.iinfo(base.dtype)
167
+ low, high = int(info.min), int(info.max)
168
+ span = high - low + 1 # 2**bits
169
+ stored_type = base.dtype.type # values of this type always fit
170
+ else:
171
+ low = None
172
+ first_overflow = None
173
+ with np.errstate(over="raise"):
174
+ for node in it.product(range(nrows), repeat=ndim):
175
+ if node.count(0) >= ndim - 1:
176
+ continue # on an edge
177
+ args = []
178
+ for offset in offsets:
179
+ target = tuple(0 if o is None else n + o
180
+ for n, o in zip(node, offset))
181
+ args.append(zero if min(target) < 0 else base[target])
182
+ try:
183
+ value = func(args)
184
+ except FloatingPointError:
185
+ # numpy reports the overflow; keep its wrapped value.
186
+ with np.errstate(over="ignore"):
187
+ value = func(args)
188
+ if first_overflow is None:
189
+ first_overflow = node
190
+ if (low is not None and type(value) is not stored_type
191
+ and isinstance(value, (int, np.integer))):
192
+ v = int(value)
193
+ if not low <= v <= high:
194
+ value = (v - low) % span + low # wrap, as numpy does
195
+ if first_overflow is None:
196
+ first_overflow = node
197
+ base[node] = value
198
+
199
+ if first_overflow is not None:
200
+ warnings.warn(
201
+ f"ndplex: node values overflowed {base.dtype} from node "
202
+ f"{first_overflow} on (in generation order); that node and "
203
+ f"those computed from it are wrong. Use dtype=object for exact "
204
+ f"integers, or reduce inside func (e.g. a modulus).",
205
+ RuntimeWarning, stacklevel=2)
206
+
207
+ obj = base.view(cls)
208
+ obj.edges, obj.func, obj.arg_locs = edges, func, arg_locs
209
+ return obj
210
+
211
+ # --- numpy subclass plumbing ---------------------------------------------
212
+
213
+ def __array_finalize__(self, obj):
214
+ self.edges = getattr(obj, "edges", None)
215
+ self.func = getattr(obj, "func", None)
216
+ self.arg_locs = getattr(obj, "arg_locs", None)
217
+
218
+ def __array_wrap__(self, obj, context=None, return_scalar=False):
219
+ out = super().__array_wrap__(obj, context)
220
+ if out.ndim == 0:
221
+ return out[()] # reductions give plain scalars
222
+ return out
223
+
224
+ def __reduce__(self):
225
+ constructor, args, state = super().__reduce__()
226
+ func = self.func
227
+ try:
228
+ pickle.dumps(func)
229
+ except Exception:
230
+ func = None
231
+ return constructor, args, (state, (self.edges, func, self.arg_locs))
232
+
233
+ def __setstate__(self, state):
234
+ array_state, (edges, func, arg_locs) = state
235
+ super().__setstate__(array_state)
236
+ self.edges, self.func, self.arg_locs = edges, func, arg_locs
237
+
238
+ @property
239
+ def nrows(self):
240
+ """Length of each edge, from the shape; None if the sides differ."""
241
+ s = self.shape
242
+ if s and all(x == s[0] for x in s):
243
+ return s[0]
244
+ return None
245
+
246
+ # --- lines and cross-sections --------------------------------------------
247
+
248
+ def _line(self, loc, move):
249
+ """Values along the moves from loc, and where they lie in the array.
250
+
251
+ Returns (values, inside): plain arrays of the same shape, one axis
252
+ per move (last move first) plus any axes loc does not fix. Positions
253
+ outside the array hold zero and are False in `inside`.
254
+ """
255
+ a = np.asarray(self)
256
+ if len(loc) == 0:
257
+ loc = (0,) * a.ndim
258
+ k = len(loc)
259
+ if len(move) == 0:
260
+ move = (1,) * k
261
+ if np.isscalar(move[0]):
262
+ move = [move]
263
+ if k > a.ndim:
264
+ raise ValueError(f"loc has {k} coordinates; the array has "
265
+ f"{a.ndim} dimensions")
266
+ for m in move:
267
+ if len(m) != k:
268
+ raise ValueError(f"each move needs {k} coordinates (as many "
269
+ f"as loc), got {tuple(m)}")
270
+
271
+ steps = np.arange(max(a.shape))
272
+ coords = []
273
+ for dim in range(k):
274
+ c = loc[dim]
275
+ for i in range(len(move)):
276
+ step_grid = np.broadcast_to(steps, (len(steps),) * (i + 1)).T
277
+ c = step_grid * move[i][dim] + c
278
+ coords.append(c)
279
+ coords = np.stack(coords)
280
+
281
+ bounds = np.array(a.shape[:k]).reshape((k,) + (1,) * (coords.ndim - 1))
282
+ inside = ((coords >= 0) & (coords < bounds)).all(axis=0)
283
+ index = tuple(np.where(inside, coords, 0))
284
+ values = a[index]
285
+ rest = (1,) * (values.ndim - inside.ndim)
286
+ inside = np.broadcast_to(inside.reshape(inside.shape + rest),
287
+ values.shape)
288
+ values[~inside] = 0
289
+ return values, inside
290
+
291
+ def diag(self, loc=(), move=(), sum_axis=None):
292
+ """
293
+ A diagonal: a line or cross-section through the array.
294
+
295
+ Starts at the node (or, with fewer coordinates than dimensions, the
296
+ sub-array) `loc` and steps through the array by `move`, returning
297
+ the values it passes. With several moves, the result has one axis
298
+ per move, the last move first.
299
+
300
+ Parameters
301
+ ----------
302
+ loc : tuple of ints
303
+ Starting coordinates. Default: the origin.
304
+ move : tuple of ints, or list of such tuples
305
+ Step(s), each with as many coordinates as loc. Default: one step
306
+ along every axis that loc fixes (with the default loc, the main
307
+ diagonal).
308
+ sum_axis : None or int
309
+ If given, sum the result over this axis.
310
+
311
+ Returns
312
+ -------
313
+ numpy.ndarray (0-d for a single node), or its sums over sum_axis.
314
+
315
+ Notes
316
+ -----
317
+ The result ends where the moves leave the array, so its length is
318
+ set by the geometry, not the values: genuine trailing zeros are
319
+ kept, and a line of zeros returns zeros. Positions outside the array
320
+ but before the end read as 0.
321
+ """
322
+ values, inside = self._line(loc, move)
323
+ values = np.squeeze(values)
324
+ inside = np.squeeze(inside)
325
+ result = _trim_outside(values, inside)
326
+ if sum_axis is None:
327
+ return result
328
+ return result.sum(axis=sum_axis)
329
+
330
+ def as_tri(self, start=0, end=None, sums=False):
331
+ """
332
+ The array as a triangle (2-D), tetrahedron (3-D), 4-simplex (4-D),
333
+ and so on: the series of (n-1)-dimensional cross-sections through
334
+ the array along its anti-diagonals.
335
+
336
+ In the simplest case, Pascal's rectangle as Pascal's triangle,
337
+ justified left. Every layer is justified left (toward index 0 on
338
+ each axis): element [k][x_1, ..., x_(n-1)] is the node whose
339
+ coordinates are x_1, ..., x_(n-1) on the axes other than axis n-2,
340
+ and k - (x_1 + ... + x_(n-1)) on axis n-2. In 4-D, tri[k][a, b, c]
341
+ is the node (a, b, k - a - b - c, c). Returns the result (it does
342
+ not print it).
343
+
344
+ Parameters
345
+ ----------
346
+ start, end : int
347
+ First and last row (2-D) or layer (3-D and up). Default: all.
348
+ sums : bool
349
+ If True, return the sum of each row or layer instead.
350
+
351
+ Returns
352
+ -------
353
+ numpy.ndarray
354
+ If sums is False, shape (end - start + 1,) + (end + 1,) * (ndim - 1),
355
+ with each cross-section padded with zeros on the right. If sums is
356
+ True, the 1-D array of sums. A genuine zero in a cross-section is
357
+ indistinguishable from the padding.
358
+ """
359
+ a = np.asarray(self)
360
+ if a.ndim < 2:
361
+ raise ValueError("as_tri needs an array of at least 2 dimensions")
362
+ if any(x != a.shape[0] for x in a.shape):
363
+ raise ValueError(f"as_tri needs equal sides; the shape is {a.shape}")
364
+ ndim = a.ndim
365
+ if end is None:
366
+ end = a.shape[0] - 1
367
+
368
+ # The cross-section of layer i starts at the node with i on the
369
+ # pivot axis (ndim - 2) and 0 elsewhere. Each move takes one step
370
+ # from the pivot axis to another axis: first the last axis, then
371
+ # the axes before the pivot, nearest first.
372
+ # 2-D: [(-1, 1)]
373
+ # 3-D: [(0, -1, 1), (1, -1, 0)]
374
+ # 4-D: [(0, 0, -1, 1), (0, 1, -1, 0), (1, 0, -1, 0)]
375
+ # So every layer is a left-justified simplex: in 4-D, element
376
+ # [a, b, c] of layer i is the node (a, b, i - a - b - c, c).
377
+ # (The notebook's pattern took later moves' steps from the
378
+ # neighbouring axis instead, which sheared layers in 4-D and up;
379
+ # 2-D and 3-D are unchanged.)
380
+ pivot = ndim - 2
381
+ unit = np.eye(ndim, dtype=int)
382
+ targets = [ndim - 1] + list(range(pivot - 1, -1, -1))
383
+ moves = np.array([unit[t] - unit[pivot] for t in targets])
384
+ loc = [0] * ndim # the cross-section starts at loc[pivot] = i
385
+
386
+ width = (end + 1,) * (ndim - 1)
387
+ rows = []
388
+ for i in range(start, end + 1):
389
+ loc[pivot] = i
390
+ values, inside = self._line(tuple(loc), moves)
391
+ section = _trim_outside(values, inside)
392
+ row = np.zeros(width, dtype=a.dtype)
393
+ row[tuple(slice(s) for s in section.shape)] = section
394
+ rows.append(row)
395
+
396
+ tri = np.stack(rows, axis=0)
397
+ if sums:
398
+ return tri.sum(axis=tuple(range(1, ndim)))
399
+ return tri
ndplex/_oeis.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ oeis(): look up each row of an array in the On-Line Encyclopedia of Integer
3
+ Sequences (https://oeis.org).
4
+
5
+ Original function: Ted Teske, 2019-2024 (the notebook ndplex_array.ipynb).
6
+ Package version (https, a timeout, standard library only, failures reported
7
+ as failures, a User-Agent naming ndplex): Ted Teske with Claude (Anthropic),
8
+ 2026.
9
+ """
10
+ import urllib.parse
11
+ import urllib.request
12
+
13
+ import numpy as np
14
+
15
+ __all__ = ["oeis"]
16
+
17
+ SEARCH_URL = "https://oeis.org/search"
18
+ USER_AGENT = "ndplex/{version} (+https://github.com/tedteske/ndplex)"
19
+ _GREETING = "# Greetings from The On-Line Encyclopedia"
20
+
21
+
22
+ def urlopen(url, timeout):
23
+ """The network call, kept separate so tests can replace it.
24
+
25
+ The request names ndplex in its User-Agent header. oeis.org refuses
26
+ Python's default one ("Python-urllib/3.x") with HTTP 403 (Cloudflare
27
+ error 1010), checked from Google Colab on 7 Nov 2026.
28
+ """
29
+ from . import __version__ # here, not at the top: ndplex imports this module
30
+ request = urllib.request.Request(
31
+ url, headers={"User-Agent": USER_AGENT.format(version=__version__)})
32
+ with urllib.request.urlopen(request, timeout=timeout) as response:
33
+ return response.read().decode("utf-8", errors="replace")
34
+
35
+
36
+ def _lookup(sequence, timeout):
37
+ """Best OEIS match for `sequence` as (A-number, name), or None.
38
+
39
+ Raises OSError if OEIS cannot be reached and ValueError if the answer
40
+ is not in OEIS's text format.
41
+ """
42
+ query = urllib.parse.urlencode(
43
+ {"q": ",".join(str(s) for s in sequence), "n": "1", "fmt": "text"})
44
+ text = urlopen(f"{SEARCH_URL}?{query}", timeout)
45
+ for line in text.splitlines():
46
+ if line.startswith("%N "):
47
+ anum, _, name = line[3:].partition(" ")
48
+ return anum, name
49
+ if text.lstrip().startswith(_GREETING):
50
+ return None # a well-formed answer with no sequence in it
51
+ raise ValueError("unrecognized response from oeis.org")
52
+
53
+
54
+ def oeis(array, size=None, trim=False, quiet=False, timeout=10):
55
+ """
56
+ Search OEIS for the sequence in each row of an array.
57
+
58
+ Parameters
59
+ ----------
60
+ array : array_like, 1-D or 2-D
61
+ A 1-D array is searched as one sequence; a 2-D array row by row.
62
+ size : int
63
+ Number of rows to search (2-D only). Default: all rows.
64
+ trim : bool
65
+ If True, trim zeros from the start and end of each row first (useful
66
+ for the rows of `as_tri`).
67
+ quiet : bool
68
+ If False (the default), print one line per row and return None, as
69
+ the notebook did. If True, print nothing and return the results.
70
+ timeout : float
71
+ Seconds to wait for each answer from oeis.org.
72
+
73
+ Returns
74
+ -------
75
+ None, or (with quiet=True) a list with one entry per row searched (one
76
+ entry for 1-D input): (A-number, name) for the best match, or None if
77
+ OEIS has no match.
78
+
79
+ Raises
80
+ ------
81
+ With quiet=True only: OSError (including urllib.error.URLError and
82
+ TimeoutError) if oeis.org cannot be reached, ValueError if its answer is
83
+ not in the expected format. When printing, these are reported on the
84
+ row's line as "lookup failed (...)" instead.
85
+
86
+ Notes
87
+ -----
88
+ Each row is a separate request to oeis.org, which needs a network
89
+ connection. Please search a few rows at a time.
90
+ """
91
+ array = np.asarray(array)
92
+ if array.ndim == 1:
93
+ rows, labels = [array], [None]
94
+ elif array.ndim == 2:
95
+ n = len(array) if size is None else size
96
+ rows, labels = list(array[:n]), list(range(n))
97
+ else:
98
+ raise ValueError(f"oeis takes a 1-D or 2-D array, not {array.ndim}-D")
99
+
100
+ if not quiet:
101
+ print("OEIS Search Results")
102
+ print("===================")
103
+
104
+ results = []
105
+ for row, label in zip(rows, labels):
106
+ sequence = np.trim_zeros(row) if trim else row
107
+ if quiet:
108
+ results.append(_lookup(sequence, timeout))
109
+ continue
110
+ prefix = "" if label is None else f"row {label}: "
111
+ try:
112
+ match = _lookup(sequence, timeout)
113
+ except (OSError, ValueError) as err:
114
+ print(f"{prefix}lookup failed ({err})")
115
+ continue
116
+ if match is None:
117
+ print(f"{prefix}no match")
118
+ else:
119
+ print(f"{prefix}https://oeis.org/{match[0]} {match[1]}")
120
+ return results if quiet else None
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: ndplex
3
+ Version: 0.1.0
4
+ Summary: The n-Dimensional m-Plex array: a generic Pascal's array (rectangle, cube or higher) with user-defined edges and node-generating function.
5
+ Project-URL: Homepage, https://github.com/tedteske/ndplex
6
+ Project-URL: Source, https://github.com/tedteske/ndplex
7
+ Project-URL: Issues, https://github.com/tedteske/ndplex/issues
8
+ Project-URL: Changelog, https://github.com/tedteske/ndplex/blob/master/CHANGELOG.md
9
+ Author: Ted Teske
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: binomial,combinatorics,integer sequences,multinomial,numpy,oeis,pascal's array,pascal's triangle,simplex
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Education
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: 3.15
25
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
26
+ Requires-Python: >=3.10
27
+ Requires-Dist: numpy>=1.23.5
28
+ Description-Content-Type: text/markdown
29
+
30
+ # ndplex
31
+
32
+ **The n-Dimensional m-Plex array: a generic Pascal's array for NumPy.**
33
+
34
+ `ndplex` builds Pascal's rectangle, cube or hypercube as a NumPy array, and lets you change everything that defines it: the values on its edges, the rule that generates each interior node, and which neighbours that rule reads. It can then cut lines and cross-sections through the array, show the array as a triangle, tetrahedron or n-simplex, and look its rows up in the [On-Line Encyclopedia of Integer Sequences](https://oeis.org).
35
+
36
+ ## Install
37
+
38
+ ```
39
+ pip install ndplex
40
+ ```
41
+
42
+ In Google Colab, run `!pip install ndplex` in a cell. ndplex needs Python 3.10 or later and NumPy 1.23.5 or later, nothing else. `oeis` needs a network connection when it is called.
43
+
44
+ ## Quick start
45
+
46
+ By default, `ndplex(n)` is Pascal's rectangle: ones on the top and left edges, and every other node the sum of the node above it and the node to its left.
47
+
48
+ ```pycon
49
+ >>> from ndplex import ndplex
50
+ >>> p = ndplex(6)
51
+ >>> print(p)
52
+ [[ 1 1 1 1 1 1]
53
+ [ 1 2 3 4 5 6]
54
+ [ 1 3 6 10 15 21]
55
+ [ 1 4 10 20 35 56]
56
+ [ 1 5 15 35 70 126]
57
+ [ 1 6 21 56 126 252]]
58
+ ```
59
+
60
+ `as_tri` shows the same array as Pascal's triangle, justified left, or returns the sum of each row.
61
+
62
+ ```pycon
63
+ >>> print(p.as_tri())
64
+ [[ 1 0 0 0 0 0]
65
+ [ 1 1 0 0 0 0]
66
+ [ 1 2 1 0 0 0]
67
+ [ 1 3 3 1 0 0]
68
+ [ 1 4 6 4 1 0]
69
+ [ 1 5 10 10 5 1]]
70
+ >>> print(p.as_tri(sums=True))
71
+ [ 1 2 4 8 16 32]
72
+ ```
73
+
74
+ `diag` walks through the array from a starting node by a fixed step. Row 4 of the triangle is an anti-diagonal of the rectangle; the main diagonal holds the central binomial coefficients.
75
+
76
+ ```pycon
77
+ >>> print(p.diag(loc=(4, 0), move=(-1, 1)))
78
+ [1 4 6 4 1]
79
+ >>> print(p.diag())
80
+ [ 1 2 6 20 70 252]
81
+ ```
82
+
83
+ The steeper diagonals sum to the Fibonacci numbers:
84
+
85
+ ```pycon
86
+ >>> f = ndplex(10)
87
+ >>> print([int(f.diag(loc=(k, 0), move=(-2, 1)).sum()) for k in range(10)])
88
+ [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
89
+ ```
90
+
91
+ ## Other arrays
92
+
93
+ `ndplex(nrows, edges=(1, 1), arg_locs=None, func=sum, dtype=np.int64)`
94
+
95
+ - **`edges`** sets the front edges, as numbers or as sequences of length `nrows`. Their number sets the dimension. In 2-D, `edges[0]` fills column 0 and `edges[1]` fills row 0.
96
+ - **`func`** generates each interior node from a list of argument values. The default is `sum`.
97
+ - **`arg_locs`** gives where those arguments are, relative to the node. The default in 2-D is `[[-1, 0], [0, -1]]`, the nodes above and to the left. A `None` coordinate means "on the edge".
98
+
99
+ Odd numbers along the top edge give the squares, the square pyramidal numbers, and so on down the rows:
100
+
101
+ ```pycon
102
+ >>> print(ndplex(5, edges=(1, [1, 3, 5, 7, 9])))
103
+ [[ 1 3 5 7 9]
104
+ [ 1 4 9 16 25]
105
+ [ 1 5 14 30 55]
106
+ [ 1 6 20 50 105]
107
+ [ 1 7 27 77 182]]
108
+ ```
109
+
110
+ Pascal's rule modulo 2 gives the Sierpinski pattern:
111
+
112
+ ```pycon
113
+ >>> print(ndplex(6, func=lambda x: sum(x) % 2))
114
+ [[1 1 1 1 1 1]
115
+ [1 0 1 0 1 0]
116
+ [1 1 0 0 1 1]
117
+ [1 0 0 0 1 0]
118
+ [1 1 1 1 0 0]
119
+ [1 0 1 0 0 0]]
120
+ ```
121
+
122
+ Adding the diagonal neighbour as a third argument gives the Delannoy numbers:
123
+
124
+ ```pycon
125
+ >>> print(ndplex(6, arg_locs=[[-1, 0], [0, -1], [-1, -1]]))
126
+ [[ 1 1 1 1 1 1]
127
+ [ 1 3 5 7 9 11]
128
+ [ 1 5 13 25 41 61]
129
+ [ 1 7 25 63 129 231]
130
+ [ 1 9 41 129 321 681]
131
+ [ 1 11 61 231 681 1683]]
132
+ ```
133
+
134
+ ## Higher dimensions
135
+
136
+ Three edges make Pascal's cube; `as_tri` shows it as a tetrahedron, layer by layer. Each layer holds the trinomial coefficients, and the layers sum to powers of 3.
137
+
138
+ ```pycon
139
+ >>> c = ndplex(4, edges=3)
140
+ >>> print(c.as_tri()[2])
141
+ [[1 2 1 0]
142
+ [2 2 0 0]
143
+ [1 0 0 0]
144
+ [0 0 0 0]]
145
+ >>> print(c.as_tri(sums=True))
146
+ [ 1 3 9 27]
147
+ ```
148
+
149
+ The same works in any number of dimensions: `ndplex(5, edges=4)` is a 4-D array, and its `as_tri` is a 4-simplex.
150
+
151
+ ## Large values
152
+
153
+ Node values grow quickly. With the default `int64`, the 2-D array is exact for nodes with `i + j <= 66`. If any node overflows, construction issues one `RuntimeWarning` naming the first node that did. For exact integers of any size, use `dtype=object`:
154
+
155
+ ```pycon
156
+ >>> print(ndplex(70, dtype=object)[69, 69])
157
+ 23623985175715118288974865541854103729000
158
+ ```
159
+
160
+ When only a residue is needed, reduce inside `func` instead, as in the Sierpinski example above.
161
+
162
+ ## Looking rows up in OEIS
163
+
164
+ `oeis` searches oeis.org for each row of an array and prints the best match. It sends one request per row, so please search a few rows at a time.
165
+
166
+ ```python
167
+ from ndplex import ndplex, oeis
168
+
169
+ oeis(ndplex(5))
170
+ oeis(ndplex(9).as_tri(), trim=True, size=5) # rows of the triangle, zeros trimmed
171
+ matches = oeis(ndplex(5), quiet=True) # a list of (A-number, name) or None
172
+ ```
173
+
174
+ The output has one line per row, for example `row 2: https://oeis.org/A000217 Triangular numbers: ...`. The names come from OEIS and can change. If oeis.org cannot be reached or refuses the request, the row's line says `lookup failed (...)`. Each request identifies itself to oeis.org as `ndplex/<version>`.
175
+
176
+ ## Plotting
177
+
178
+ From 0.1.0, plotting is left to the user, so `import ndplex` needs only NumPy. The notebook's `plot_ndplex` and `diag_as_hist` are in [`ndplex_array.ipynb`](https://github.com/tedteske/ndplex/blob/master/ndplex_array.ipynb) in the repository; an examples notebook that installs from PyPI is planned.
179
+
180
+ ## Changes from the notebook
181
+
182
+ ndplex began as the first code cell of `ndplex_array.ipynb` (2019–2024). The package keeps the notebook's results except where a change is documented. The changes are listed in the [changelog](https://github.com/tedteske/ndplex/blob/master/CHANGELOG.md), with migration notes in [`docs/`](https://github.com/tedteske/ndplex/tree/master/docs). In brief: no import side effects, plotting left out, overflow reported, `arg_locs` outside the array read 0, `diag` ends where its moves leave the array, and `as_tri` layers are justified left in every dimension.
183
+
184
+ ## Background and credits
185
+
186
+ ndplex is the original work of Ted Teske (2019–2024). A general source for it is Thomas M. Green, *The Simplex, Duplex and Pascal's Triangles: Relatives of Pascal's Triangle, with Excursions Into Hyperspace* (CreateSpace, 2015; ISBN 9781514677094).
187
+
188
+ The packaging, tests and audit (2026) were developed with assistance from Claude (Anthropic). [`ATTRIBUTION.md`](https://github.com/tedteske/ndplex/blob/master/ATTRIBUTION.md) sets out which work is whose, and the references.
189
+
190
+ ## Citing
191
+
192
+ Citation metadata are in [`CITATION.cff`](https://github.com/tedteske/ndplex/blob/master/CITATION.cff); GitHub's "Cite this repository" button reads it.
193
+
194
+ ## License
195
+
196
+ MIT. Copyright (c) 2019 Ted Teske.
@@ -0,0 +1,7 @@
1
+ ndplex/__init__.py,sha256=fJBOCDLp99Qlv6646z8ZIqe6mO39k1rIeTazXdnHy3Y,1480
2
+ ndplex/_core.py,sha256=1BFjIPM-KgUJqUe7aflM6Vstbo3yBdPra0BCGsL7Kv0,17046
3
+ ndplex/_oeis.py,sha256=kOV2k2GQ5RkpnXkw57of7qpG2wlxe1g4f9P7-74IXM8,4338
4
+ ndplex-0.1.0.dist-info/METADATA,sha256=Fw5mKK57R0oRyZERuZFhtkLSPQg3RcsKT5ix378umZs,8010
5
+ ndplex-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ ndplex-0.1.0.dist-info/licenses/LICENSE,sha256=SKsS0t2kxvXf8LvmoW2kQ0v5dMW19CQUe_l1vgx01r0,1066
7
+ ndplex-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Ted Teske
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.