#
##
## SPDX-FileCopyrightText: © 2007-2024 Benedict Verhegghe <bverheg@gmail.com>
## SPDX-License-Identifier: GPL-3.0-or-later
##
## This file is part of pyFormex 3.5 (Thu Feb 8 19:11:13 CET 2024)
## pyFormex is a tool for generating, manipulating and transforming 3D
## geometrical models by sequences of mathematical operations.
## Home page: https://pyformex.org
## Project page: https://savannah.nongnu.org/projects/pyformex/
## Development: https://gitlab.com/bverheg/pyformex
## Distributed under the GNU General Public License version 3 or later.
##
## This program is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation, either version 3 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program. If not, see http://www.gnu.org/licenses/.
##
"""Working with variable width tables.
Mesh type geometries use tables of integer data to store the connectivity
between different geometric entities. The basic connectivity table in a
Mesh with elements of the same type is a table of constant width: the
number of nodes connected to each element is constant.
However, the inverse table (the elements connected to each node) does not
have a constant width.
Tables of constant width can conveniently be stored as a 2D array, allowing
fast indexing by row and/or column number. A variable width table can be
stored (using arrays) in two ways:
- as a 2D array, with a width equal to the maximal row length.
Unused positions in the row are then filled with an invalid value (-1).
- as a 1D array, storing a simple concatenation of the rows.
An additional array then stores the position in that array of the first
element of each row. It is very efficient if the data (or at least the
row lengths) do not change during the lifetime of the table (see Varray).
- as a 1D array where the actual rows do not have to be contiguous. A start
and stop marker is held for each row, and the unused element between
subsequent rows can be used to efficiently grow the rows at their ends.
This is the current Dynarray implementation. It could be a welcome
replacement for a Varray or an array with -1 fill values, but a real
use case has not yet been found.
"""
import numpy as np
from pyformex import arraytools as at
[docs]class Dynarray():
"""A variable width 2D integer array
This class provides an efficient way to store tables of
nonnegative integers when the rows of the table may have
different length.
For large tables this may allow an important memory saving
compared to a rectangular array where the non-existent entries
are filled by some special value.
Data in the Dynarray are stored as a single 1D array,
containing the concatenation of all rows.
An index is kept with the start position of each row in the 1D array.
Parameters
----------
data:
Data to initialize to a new Dynarray object. This can either of:
data is anything that
- has a length: nrows
- has an iterator
- each item has a length
- each item contains a sequence of ints
- another Dynarray instance: a shallow copy of the Dynarray is created.
- a list of lists of integers. Each item in the list contains
one row of the table.
- a 2D ndarray of integer type. The nonnegative numbers on each row
constitute the data for that row.
- a 1D array or list of integers, containing the concatenation of
the rows. The second argument `ind` specifies the indices of the
first element of each row.
- a 1D array or list of integers, containing the concatenation of
the rows obtained by prepending each row with the row length.
The caller should make sure these 1D data are consistent.
ind: 1-dim int :term:`array_like`, optional
This is only used when `data` is a pure concatenation of all rows.
It holds the position in `data` of the first element of each row.
Its length is equal to the number of rows (`nrows`) or `nrows+1`.
It is a non-decreasing series of integer values, starting with 0.
If it has ``nrows+1`` entries, the last value is equal to the total
number of elements in `data`. This last value may be omitted,
and will then be added automatically.
Note that two subsequent elements may be equal, corresponding with
an empty row.
**Attributes**
Attributes
----------
nrows: int
The number of rows in the table
width: int
The length of the longest row in the table
size: int
The total number of entries in the table
shape: tuple of two ints
The combined (``nrows``,``width``) values.
Examples
--------
Create a Dynarray is by default printed in user-friendly format:
>>> Da = Dynarray([[0],[1,2],[0,2,4],[0,2]])
>>> Da
Dynarray([[0], [1, 2], [0, 2, 4], [0, 2]])
The Dynarray prints in a user-friendly format:
>>> print(Da)
Dynarray (4,3)
[0]
[1 2]
[0 2 4]
[0 2]
<BLANKLINE>
Show info about the Dynarray
>>> print(Da.nrows, Da.width, Da.shape)
4 3 (4, 3)
>>> print(Da.size, Da.lengths)
8 [1 2 3 2]
Initialize from another Dynarray return a deep copy
>>> Db = Dynarray(Da)
>>> str(Db) == str(Da)
True
Indexing: The data for any row can be obtained by simple indexing:
>>> print(Da[1])
[1 2]
This is equivalent with
>>> print(Da.row(1))
[1 2]
Change elements:
>>> Da[1][0] = 3
>>> print(Da[1])
[3 2]
>>> str(Db) == str(Da)
False
Full row can be changed with matching length:
>>> Da[1] = [1, 2]
>>> print(Da[1])
[1 2]
Negative indices are allowed:
>>> print(Da.row(-1))
[0 2]
Extracted columns are filled with -1 values where needed
>>> print(Da.col(1))
[-1 2 2 2]
Select takes multiple rows using indices or bool:
>>> print(Da.select([1,3]))
Dynarray (2,2)
[1 2]
[0 2]
<BLANKLINE>
>>> print(Da.select(Da.lengths==2))
Dynarray (2,2)
[1 2]
[0 2]
<BLANKLINE>
Iterator: A Dynarray provides its own iterator:
>>> for row in Da:
... print(row)
[0]
[1 2]
[0 2 4]
[0 2]
>>> print(Da.flat)
[0 1 2 0 2 4 0 2]
>>> print(list(Da))
[array([0]), array([1, 2]), array([0, 2, 4]), array([0, 2])]
>>> print(Dynarray())
Dynarray (0,0)
<BLANKLINE>
>>> L,R = Da.sameLength()
>>> print(L)
[1 2 3]
>>> print(R)
[array([0]), array([1, 3]), array([2])]
>>> for a in Da.split():
... print(a)
[[0]]
[[1 2]
[0 2]]
[[0 2 4]]
"""
def __init__(self, data=None, shape=(0, 0), fill=-1, dtype=np.int32):
"""Initialize the Dynarray. See the class docstring."""
a = np.full(shape=shape, fill_value=fill, dtype=dtype)
start = np.zeros(shape=shape[:1], dtype=dtype)
stop = np.zeros(shape=shape[:1], dtype=dtype)
# Store the data
self._data = a
self._start = start
self._stop = stop
self._fill = fill
if data is not None:
self.data = data
@property
def data(self):
"""Return the data array"""
return self._data
@data.setter
def data(self, data):
"""Replace the data array with new data"""
nrows = len(data)
maxwidth = max([len(row) for row in data])
self.__init__(shape=(nrows, maxwidth), fill=self._fill)
for i, r in enumerate(data):
self[i] = r
@property
def start(self):
return self._start
@start.setter
def start(self, start):
self._start = start
@property
def stop(self):
return self._stop
@stop.setter
def stop(self, stop):
self._start = stop
def __getitem__(self, i):
"""Return the data of row i"""
if not at.isInt(i):
raise ValueError("Requires an int argument")
return self._data[i][self._start[i]:self._stop[i]]
def __setitem__(self, i, rowdata):
"""Set the data of row i"""
if not at.isInt(i):
raise ValueError("Requires an int argument")
if at.isInt(rowdata):
# Set all items of row i to this value
self._data[i] = rowdata
else:
# Set row i to the contents of rowdata
rowlen = len(rowdata)
self.resize(rowlen)
self._data[i][:rowlen] = rowdata
self._data[i][rowlen:] = self._fill
self._start[i] = 0
self._stop[i] = len(rowdata)
@property
def lengths(self):
"""Return the length of all rows of the Dynarray"""
return self._stop - self._start
@property
def nrows(self):
"""Return the number of rows in the Dynarray"""
return self._data.shape[0]
@property
def width(self):
"""Return the max length the rows of the Dynarray can have"""
return self._data.shape[1]
@property
def size(self):
"""Return the total number of elements in the Dynarray"""
return self.lengths.sum()
@property
def shape(self):
"""Return a tuple with the number of rows and maximum row length"""
return self._data.shape
@property
def flat(self):
"""Return all sequential row data as a flat array."""
return np.concatenate([r for r in self])
[docs] def resize(self, width):
"""Change the maxwidth of the table"""
maxwidth = self._data.shape[1]
if width > maxwidth:
self._data = at.growAxis(self._data, width-maxwidth, fill=self._fill)
def __len__(self):
"""Allow len(Dynarray)"""
return self.nrows
[docs] def length(self, i):
"""Return the length of row i"""
return self._stop[i] - self._start[i]
[docs] def row(self, i):
"""Return the data for row i
This returns self[i].
"""
return self[i]
[docs] def setRow(self, i, data):
"""Replace the data of row i
This is equivalent to self[i] = data.
"""
self[i] = data
[docs] def col(self, i):
"""Return the data for column i
This always returns a list of length nrows.
For rows where the column index i is missing, a value self._fill is returned.
"""
return np.array([r[i] if i in range(-len(r), len(r)) else self._fill
for r in self])
[docs] def select(self, sel):
"""Select some rows from the Dynarray.
Parameters
----------
sel: iterable of ints or bools
Specifies the row(s) to be selected.
If type is int, the values are the row numbers.
If type is bool, the length of the iterable should be
exactly ``self.nrows``; the positions where the value is True are
the rows to be returned.
Returns
-------
Dynarray object
A Dynarray with only the selected rows.
Examples
--------
>>> Da = Dynarray([[0],[1,2],[0,2,4],[0,2]])
>>> Da.select((1,3))
Dynarray([[1, 2], [0, 2]])
>>> Da.select((False,True,False,True))
Dynarray([[1, 2], [0, 2]])
"""
sel = np.asarray(sel) # this is important, because Python bool isInt
if len(sel) > 0 and not at.isInt(sel[0]):
sel = np.where(sel)[0]
return Dynarray([self[j] for j in sel])
def __iter__(self):
"""Return an iterator for the Dynarray"""
self._row = 0
return self
def __next__(self):
"""Return the next row of the Dynarray"""
if self._row >= self.nrows:
raise StopIteration
row = self[self._row]
self._row += 1
return row
[docs] def sort(self):
"""Sort the rows of the Dynarray.
Sorting a Dynarray sorts the elements in each row.
The sorting is done inplace.
In most applications, two Dynarrays are considered equal if they
have the same number of rows and all rows contain the same values,
independent of their order. Rows can be sorted to create
a unique representation.
Examples
--------
>>> Da = Dynarray([[0],[2,1],[4,0,2],[0,2]])
>>> Da.sort()
>>> print(Da)
Dynarray (4,3)
[0]
[1 2]
[0 2 4]
[0 2]
<BLANKLINE>
"""
for i in range(self.nrows):
self[i] = sorted(self[i])
[docs] def pop(self, i, j=-1):
"""Pop a value from row i at index j
Parameters
----------
i: int
Index of the row from which to pop the value.
j: int, optional
Index of the element to pop. Default is the last element.
Returns
-------
int:
The value popped from the row i.
Examples
--------
>>> Da = Dynarray([[0],[1,2],[0,2,4],[0,2]])
>>> print(Da.pop(1))
2
>>> print(Da.pop(0,0))
0
>>> print(Da.pop(2,1))
2
>>> print(Da)
Dynarray (4,3)
[]
[1]
[0 4]
[0 2]
<BLANKLINE>
"""
val = self[i][j]
if j == 0:
self._start[i] += 1
elif j == -1:
self._stop[i] -= 1
else:
if j < 0:
j += self._stop[i]
if j < 0 or j >= self._stop[i]:
raise ValueError("IndexError: index out of range")
self._data[i][j:self._stop[i]-1] = self._data[i][j+1:self._stop[i]]
self._stop[i] -= 1
return val
[docs] def insert(self, i, j, val):
"""Insert a value at position j of row i
Parameters
----------
i: int
Index of the row from which to pop the value.
j: int, optional
Position in row i where to insert the value. If negative, this
is counted from the end of the row. A value -1 will insert before
the last position. Values exceeding the end (or start) of the row,
will insert at end (or start).
val: int
The value to insert in row ``i``.
See also
--------
append: insert a value at the end of a row.
Examples
--------
>>> Da = Dynarray([[0],[1,2],[0,2,4],[0,2]])
>>> Da.insert(1,1,3)
>>> Da.insert(0,0,1)
>>> Da.insert(2,-1,5)
>>> print(Da)
Dynarray (4,4)
[1 0]
[1 3 2]
[0 2 5 4]
[0 2]
<BLANKLINE>
"""
if j == -1 and self._stop[i] < self._data.shape[1]:
self._data[i][self._stop[i]] = val
self._stop[i] += 1
elif j == 0 and self._start[i] > 0:
self._data[i][self._start[i]-1] = val
self._start[i] -= 1
else:
# This handles all cases, but less effective than
# special cases above
rowlen = self.length(i)
if j < 0:
j += rowlen
# Index beyond ends: insert at end
if j < 0:
j = 0
elif j > rowlen:
j = rowlen
# Now j is positive valid index
if rowlen >= self.width:
# need to enlarge data array
self._data = at.growAxis(self._data, 1, fill=self._fill)
# Now we certainly have space to insert
if j < rowlen // 2:
# roll forward if we can:
forward = self._start[i] > 0
else:
# roll forward if we should:
forward = self._stop[i] >= self._data.shape[1]
# shift data if needed and insert value
k = self._start[i]
if forward:
if j > 0:
self._data[i][k-1:k-1+j] = self._data[i][k:k+j]
self._data[i][k-1+j] = val
self._start[i] -= 1
else:
if j < rowlen:
self._data[i][k+j+1:k+rowlen+1] = self._data[i][k+j:k+rowlen]
self._data[i][k+j] = val
self._stop[i] += 1
[docs] def append(self, i, val):
"""Append a value at the end of a given row.
Parameters
----------
i: int
Index of the row to which to append the value.
val: int
The value to append to row ``i``.
Examples
--------
>>> Da = Dynarray([[0],[1,2],[0,2,4],[0,2]])
>>> Da.append(1,3)
>>> print(Da.width)
3
>>> Da.append(2,5)
>>> print(Da.width)
4
>>> print(Da)
Dynarray (4,4)
[0]
[1 2 3]
[0 2 4 5]
[0 2]
<BLANKLINE>
"""
while self._stop[i] >= self._data.shape[1]:
if self._start[i] > 0:
self._data[i] = np.roll(self._data[i], -1)
self._start[i] -= 1
self._stop[i] -= 1
else:
self._data = at.growAxis(self._data, 1, fill=self._fill)
self._data[i][self._stop[i]] = val
self._stop[i] += 1
[docs] def remove(self, i, val):
"""Remove a value from a given row
Parameters
----------
i: int
Index of the row from which to remove the given value.
val: int
The value to remove from row ``i``. All occurrences of this
value in row ``i`` are removed.
Examples
--------
>>> Da = Dynarray([[0],[1,2],[0,2,4,0],[0,2]])
>>> Da.remove(2,0)
>>> print(Da)
Dynarray (4,4)
[0]
[1 2]
[2 4]
[0 2]
<BLANKLINE>
"""
rem = self[i] == val
nrem = rem.sum()
if nrem > 0:
self[i] = self[i][~rem]
[docs] def sameLength(self):
"""Groups the rows according to their length.
Returns a tuple of two lists (lengths,rows):
- lengths: the sorted unique row lengths,
- rows: the indices of the rows having the corresponding length.
"""
lens = self.lengths
ulens = np.unique(lens)
return ulens, [np.where(lens == l)[0] for l in ulens]
[docs] def split(self):
"""Split the Dynarray into 2D arrays.
Returns
-------
list of arrays
A list of 2-dim arrays with the same number
of columns and the indices in the original Dynarray.
"""
return [np.asarray(self.select(ind)) for ind in self.sameLength()[1]]
[docs] def inverse(self, sort=False, ignore_neg=False):
"""Create the inverse of a Dynarray.
The inverse of a Dynarray is again a Dynarray. Values k on a row i will
become values i on row k. The number of data in both Dynarrays is thus
the same. Since values become row indices, this operation only makes
sense if there are no negative values in the Dynarray (except for the
fill value).
The inverse of the inverse is equal to the original.
Parameters
----------
sort: bool
If True, rows are sorted.
ignore_neg: bool
If True, negative values are silently ignored (and thrown away).
This will result in an inverse with less element that the original.
The default (False) will raise an error if negative values exist.
Returns
-------
Dynarray
The inverse Dynarray. If sort is True, rows are sorted.
Examples
--------
>>> a = Dynarray([[0,1],[2,0],[1,2],[4]])
>>> b = a.inverse()
>>> c = b.inverse()
>>> print(a,b,c)
Dynarray (4,2)
[0 1]
[2 0]
[1 2]
[4]
Dynarray (5,2)
[0 1]
[0 2]
[1 2]
[]
[3]
Dynarray (4,2)
[0 1]
[0 2]
[1 2]
[4]
<BLANKLINE>
"""
a = Dynarray(self)
for i in range(a.nrows):
a[i] = i
c = np.vstack([self.flat, a.flat])
neg = c[0] < 0
if neg.any():
if ignore_neg:
c = c[:, ~neg]
s = c[0].argsort()
t = c[0][s]
u = c[1][s]
v = t.searchsorted(np.arange(t.max() + 1))
v = np.append(v, len(u))
a = Dynarray([u[v[i]:v[i+1]] for i in range(len(v)-1)])
if sort:
a.sort()
return a
[docs] def tolist(self):
"""Convert the Dynarray to a nested list.
Returns
-------
list of lists
A list of all the rows as list.
Examples
--------
>>> Dynarray([[0], [1, 2], [0, 2, 4], [0, 2]]).tolist()
[[0], [1, 2], [0, 2, 4], [0, 2]]
"""
return [r.tolist() for r in self]
def __repr__(self):
"""String representation of the Dynarray
Examples
--------
>>> Dynarray([[0], [1, 2], [0, 2, 4], [0, 2]])
Dynarray([[0], [1, 2], [0, 2, 4], [0, 2]])
"""
return "%s(%s)" % (self.__class__.__name__, self.tolist())
def __str__(self):
"""Nicely print the Dynarray
Examples
--------
>>> Da = Dynarray([[0], [1, 2], [0, 2, 4], [0, 2]])
>>> print(Da)
Dynarray (4,3)
[0]
[1 2]
[0 2 4]
[0 2]
<BLANKLINE>
"""
s = "%s (%s,%s)\n" % (self.__class__.__name__, self.nrows, self.width)
for row in self:
s += ' ' + row.__str__() + '\n'
return s
if __name__ == "__main__":
# This allows running the doctests with a command
import doctest
doctest.testmod()
# End