Skip to main content

nimblend domains

Domain​

A sorted, unique set of multi-indices over named dimensions.

A coordinate resolves labels for one dimension, and a domain resolves them for a tuple of dimensions. It reports the multi-indices it has, the position of each, and the multi-index at a given position. It records which coordinates are present, and it records no numbering origin.

ConstructorReturns
Domain.full(dims, coords)every coordinate of the product dims spans
Domain.from_labels(dims, coords, labels)a domain from one label column per dimension
Domain.from_coordinates(dims, coords, index)a domain from an index matrix of one row per dimension
MemberReturns
size, dims, shape, coordsthe number of members, the dimensions, their extents and their coordinates
is_fullwhether every coordinate of the product is present
coordinates(positions=None)the multi-index of each member, or of the members at positions, as an int32 index matrix
labels(positions=None)each member's label, or the labels of the members at positions, per dimension
intersect(other)the members both have
union(other)the members either has
difference(other)the members this one has and other does not
positions_of(array)each entry of array as its position here, -1 where absent
positions_of_coordinates(index)each column of an index matrix as its position here, -1 where absent
expand(dims, coords)every member crossed with the full extent of the named dimensions
transpose(*dims)the same members, over the dimensions in the order given, or reversed when none are given
project(dims)the distinct coordinates of the members over dims, in the order given
as_coord()the domain as a coordinate, each member at its rank
array(values, absence="empty")the members with one value each, as a SparseArray
identity(into, coord, start=0)each member paired with its own position along into, valued 1.0

That table is the whole surface. A domain's codes are the raw ravelled members of the layer below it. An array's .index and .data are its raw buffers. Never read them. coordinates() and labels() report which members are present, and positions_of_coordinates reports the position of one. With positions, coordinates and labels decode those members alone. as_coord numbers them, and array and identity return an array over them. No layer above builds an index matrix. A test in this repository fails on a read of any of the three.

The label columns of from_labels are read in parallel: the k-th entry of each column belongs to the same member. A domain is a list of members, not a cross product.

import numpy as np
import nimblend as nb

coords = {
"P": nb.StoredCoord(np.array(["lisbon", "porto"])),
"W": nb.StoredCoord(np.array(["berlin", "paris", "rome"])),
}

full = nb.Domain.full(("P", "W"), coords)
pairs = nb.Domain.from_labels(
("P", "W"),
coords,
{"P": np.array(["lisbon", "porto"]), "W": np.array(["berlin", "paris"])},
)

print(full.size, pairs.size)
print(pairs.coordinates())
print(pairs.labels())
print(full.intersect(pairs).size, full.difference(pairs).size)
Output
6 2
[[0 1]
[0 1]]
{'P': array(['lisbon', 'porto'], dtype='<U6'), 'W': array(['berlin', 'paris'], dtype='<U6')}
2 4

The product spans six members. pairs has two, lisbon with berlin and porto with paris: the columns are read in parallel.

is_full reports whether a domain has every coordinate its dimensions span. A caller checks it before reading values ordered by member as a grid. full.is_full is True and pairs.is_full is False.

Querying a domain​

positions_of takes an array. positions_of_coordinates takes the index matrix behind one, and a caller with positions queries without building an array. A member the domain does not have returns -1, and a membership test is >= 0.

as_coord returns the domain as a coordinate: a member's position is its rank among the members present. This is how a dimension over a subset of a product is numbered.

import numpy as np
import nimblend as nb

coords = {
"P": nb.StoredCoord(np.array(["lisbon", "porto"])),
"W": nb.StoredCoord(np.array(["berlin", "paris", "rome"])),
}
pairs = nb.Domain.from_labels(
("P", "W"),
coords,
{"P": np.array(["lisbon", "porto"]), "W": np.array(["berlin", "paris"])},
)

asked = np.array([[0, 1, 1], [0, 1, 2]], dtype=np.int32)
print(pairs.positions_of_coordinates(asked))
print(pairs.positions_of_coordinates(asked) >= 0)

numbered = pairs.as_coord()
print(numbered.to_position(asked[:, :2]))
Output
[ 0 1 -1]
[ True True False]
[0 1]

("porto", "rome") is not a member, and the query returns -1. The other two are the first and second members of the domain, and as_coord() numbers them 0 and 1.

Crossing a domain with further dimensions​

expand replicates every member across the full extent of the given dimensions. That enumerates the coordinates a term can have, before the test of which ones it does have. The new dimensions are appended, and transpose reads the result in another order. The code of a member is its own code scaled by the appended extent, plus each position within it. The cross product is therefore arithmetic on the members, and it builds no index matrix.

import numpy as np
import nimblend as nb

coords = {
"P": nb.StoredCoord(np.array(["lisbon", "porto"])),
"W": nb.StoredCoord(np.array(["berlin", "paris", "rome"])),
"H": nb.StoredCoord(np.array([0, 1])),
}
pairs = nb.Domain.from_labels(
("P", "W"),
coords,
{"P": np.array(["lisbon", "porto"]), "W": np.array(["berlin", "paris"])},
)

hourly = pairs.expand(("H",), coords)
print(hourly.dims, hourly.size)
print(hourly.labels())
print(hourly.transpose("H", "P", "W").dims)
Output
('P', 'W', 'H') 4
{'P': array(['lisbon', 'lisbon', 'porto', 'porto'], dtype='<U6'), 'W': array(['berlin', 'berlin', 'paris', 'paris'], dtype='<U6'), 'H': array([0, 1, 0, 1])}
('H', 'P', 'W')

Two members crossed with two hours are four members, and transpose presents them over the dimensions in another order. The members present do not change.

A domain returns an array​

A caller with one value per member calls the domain, and so does a caller that pairs each member with its own position along a new dimension. Neither builds an index matrix. Both calls read above the raw members, as coordinates() and as_coord() do.

array(values) assigns one value to each member, in their stored order. The members ascend, the entries are canonical as written, and no sort runs.

import numpy as np
import nimblend as nb

coords = {"t": nb.StoredCoord(np.array([2030, 2040, 2050]))}
members = nb.Domain.full(("t",), coords)
print(members.array(np.array([1.0, 2.0, 3.0])).values())
Output
[1. 2. 3.]

The members of a full domain ascend with the ravel key. That is the order values.ravel() reads a grid in, and a whole array is built in one call.

import numpy as np
import nimblend as nb

coords = {
"x": nb.StoredCoord(np.array(["a", "b"])),
"y": nb.StoredCoord(np.array([10, 20, 30])),
}
values = np.arange(6, dtype=np.float64).reshape(2, 3)
grid = nb.Domain.full(("x", "y"), coords).array(values.ravel())
print(grid.to_dense())
Output
[[0. 1. 2.]
[3. 4. 5.]]

One value per member is the whole rule. A column of another length raises ValueError.

import numpy as np
import nimblend as nb

coords = {"t": nb.StoredCoord(np.array([2030, 2040, 2050]))}
nb.Domain.full(("t",), coords).array(np.array([1.0, 2.0]))
Raises ValueError
ValueError: a domain of 3 member(s) requires values of shape (3,); got shape (2,)

identity(into, coord, start) pairs each member with its own position along a new dimension, valued 1.0. The position of a member is its rank plus start, and the rank is its position under as_coord(). coord is the coordinate of the new dimension. It spans the whole extent the positions are numbered into. That extent is wider than these members where several domains share one numbering.

import numpy as np
import nimblend as nb

coords = {"t": nb.StoredCoord(np.array([2030, 2040, 2050]))}
members = nb.Domain.full(("t",), coords)
paired = members.identity("k", nb.ProductCoord((20,)), start=10)
print(paired.dims)
print(paired.coordinates())
Output
('t', 'k')
[[ 0 1 2]
[10 11 12]]

A destination too short for the members to be numbered raises ValueError, and it writes no position outside itself.

import numpy as np
import nimblend as nb

coords = {"t": nb.StoredCoord(np.array([2030, 2040, 2050]))}
members = nb.Domain.full(("t",), coords)
members.identity("k", nb.ProductCoord((6,)), start=4)
Raises ValueError
ValueError: 3 member(s) numbered from 4 end at position 6, and dimension 'k' has extent 6; pass a smaller start or a larger coord

The three coordinates​

A coordinate resolves the position of a label along one dimension. The dimension determines which of the three is used.

CoordinateIsUsed for
StoredCoord(labels)labels stored as an arraya dimension whose members have labels
ProductCoord(sizes)positions of a full producta dimension whose positions are computed, such as a variable's columns
SubsetCoord(codes, sizes)positions of a subset of a product, in code ordera variable over a subset, where a position is a rank among the codes

SubsetCoord gives the position of an entry as its rank among the codes. A block already in canonical order needs no lookup.

import numpy as np
import nimblend as nb

stored = nb.StoredCoord(np.array(["a", "b", "c"]))
print(stored.to_position(np.array(["c", "a"])))

product = nb.ProductCoord((2, 3))
print(product.to_position(np.array([[0, 1], [2, 0]])))

subset = nb.SubsetCoord(np.array([0, 4]), (2, 3))
print(subset.to_position(np.array([[0, 1], [0, 1]])))
Output
[2 0]
[2 3]
[0 1]

StoredCoord looks a label up among the labels it stores, and "c" resolves to position 2. ProductCoord ravels a multi-index against the sizes, and (0, 2) is position 2 and (1, 0) is position 3. SubsetCoord stores the codes 0 and 4, the same two members, and returns their ranks.

to_position takes an index matrix of one row per dimension, and each column is one entry. Every coordinate numbers its positions from 0 to its extent less one. ProductCoord and SubsetCoord raise KeyError for a cell outside their sizes.

EntryBuffer​

A fixed index and value buffer that returns successive slices.

A block computed into a reserved slice never exists as a separate object. Assembling several blocks therefore stores one copy of the result, and not one copy per block plus the result. The buffer is the destination the assembly of a model writes into: each constraint writes its rows into its own slice of one buffer.

MemberReturns
EntryBuffer(ndim, capacity)a buffer for capacity entries of ndim dimensions
reserve(n)the next n index and value slices, to write into
written()the index and values written so far
array(coords, dims, absence="empty")what was written, as an array
import numpy as np
import nimblend as nb

buffer = nb.EntryBuffer(2, 4)
index, values = buffer.reserve(2)
index[:] = np.array([[0, 1], [0, 1]])
values[:] = np.array([5.0, 6.0])

coords = {
"A": nb.StoredCoord(np.array(["a0", "a1"])),
"B": nb.StoredCoord(np.array(["b0", "b1"])),
}
array = buffer.array(coords, ("A", "B"))

print(array.nnz)
print(array.to_dense())
Output
2
[[5. 0.]
[0. 6.]]

reserve returns views of the one allocation. A write into a slice writes into the returned array.