Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Extraction and embedding

A Cartesian tensor T\mathbf T of rank nn can be split into irreducible Cartesian tensors (ICTs) Wen, 2026. An ICT is a tensor that is symmetric and traceless. An ICT of rank ℓ\ell, called its weight, has 2ℓ+12\ell + 1 independent components: a scalar for ℓ=0\ell = 0, a vector for ℓ=1\ell = 1, a symmetric traceless matrix for ℓ=2\ell = 2, and so on. The weights of the ICTs of T\mathbf T run from 0 to nn, and a weight can occur more than once; each occurrence is a channel, numbered p=1,…,Nℓp = 1, \dots, N_\ell. The tensor is a sum of parts, one for each ICT Xℓp\mathbf X_\ell^p, and two operators move between the tensor and its ICTs:

Summed over all weights and channels, the parts give back T\mathbf T. natto builds both operators for every weight and channel, keyed by (ell, p).

Dual basis

By default GG are the mapping tensors of the paper and G~\widetilde G their duals, both exact. We start with a rank-2 tensor and build its operators:

import numpy as np

from natto import act, get_embedding_operators, get_extraction_operators

T = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 12.0]])

G = get_embedding_operators(n=2)
G_tilde = get_extraction_operators(n=2)

print(f"channels (ell, p): {list(G)}")
channels (ell, p): [(0, 1), (1, 1), (2, 1)]

G and G_tilde are dictionaries with one operator per ICT, keyed by (ell, p): ell is the weight of the ICT, and p numbers the channels of that weight, counting from 1. A rank-2 tensor has 32=93^2 = 9 components, which split as 1+3+51 + 3 + 5: one scalar, one vector and one symmetric traceless matrix. So each of the weights 0, 1 and 2 occurs once, and p is always 1. At higher rank a weight can occur several times, as shown below.

Each operator prints as a sum of Kronecker deltas and Levi-Civita symbols. Lower-case letters are the indices of the ICT, and upper-case letters those of the tensor. For weight 1:

print(f"G~ of weight 1: {G_tilde[1, 1]}")
print(f"G  of weight 1: {G[1, 1]}")
G~ of weight 1: +1/2 ε_ABa
G  of weight 1: +1 ε_ABa

act applies an operator to its input. G~\widetilde G takes T to its weight-1 ICT, a vector, and GG takes that vector back to the weight-1 part of T, its antisymmetric part:

X = act(G_tilde[1, 1], T)
T_1 = act(G[1, 1], X)

print(f"X, the weight-1 ICT of T:\n{X}\n")
print(f"G · X, the weight-1 part of T:\n{T_1}")
X, the weight-1 ICT of T:
[-1.  2. -1.]

G · X, the weight-1 part of T:
[[ 0. -1. -2.]
 [ 1.  0. -1.]
 [ 2.  1.  0.]]

Arrays and einsum rules

Internally, act turns the operator into a numerical array and contracts it with the input by numpy.einsum. evaluate gives you both pieces: the array, and the einsum rule that contracts it with the input. That is useful to store the array, or to apply one operator to many tensors without evaluating it again.

from natto import evaluate

G_tilde_array, rule = evaluate(G_tilde[1, 1])
X = np.einsum(rule, G_tilde_array, T)

print(f"einsum rule: {rule}\n")
print(f"G~ of weight 1 as an array, shape {G_tilde_array.shape}:\n{G_tilde_array}\n")
print(f"X, the weight-1 ICT of T:\n{X}")
einsum rule: aAB,...AB->...a

G~ of weight 1 as an array, shape (3, 3, 3):
[[[ 0.   0.   0. ]
  [ 0.   0.   0.5]
  [ 0.  -0.5  0. ]]

 [[ 0.   0.  -0.5]
  [ 0.   0.   0. ]
  [ 0.5  0.   0. ]]

 [[ 0.   0.5  0. ]
  [-0.5  0.   0. ]
  [ 0.   0.   0. ]]]

X, the weight-1 ICT of T:
[-1.  2. -1.]

X is the same ICT as with act. In the rule, a is the index of the ICT and A, B those of the tensor, in the order of the array’s axes. The ... lets T carry leading batch axes: a stack of tensors of shape (m, 3, 3) gives a stack of m ICTs.

Several channels of one weight

A rank-3 tensor has 33=273^3 = 27 components, which split as 1+3×3+2×5+71 + 3 \times 3 + 2 \times 5 + 7: one ICT of weight 0, three of weight 1, two of weight 2 and one of weight 3. Weight 1 therefore has three channels, p = 1, 2, 3, and weight 2 has two:

G_tilde = get_extraction_operators(n=3)

print(f"channels (ell, p): {list(G_tilde)}")
channels (ell, p): [(0, 1), (1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (3, 1)]

The three channels of weight 1 are three different vectors a rank-3 tensor carries, roughly one for each pair of its indices that can be contracted away, and each has its own extraction and embedding operator. Every builder also takes ell, to build the operators of one weight only:

G_tilde = get_extraction_operators(n=3, ell=1)

print(f"channels (ell, p): {list(G_tilde)}")
channels (ell, p): [(1, 1), (1, 2), (1, 3)]

Orthonormal basis

With basis="orthonormal", the mapping tensors of each weight are orthonormalized, G^=g−1/2G\widehat G = g^{-1/2} G with gg their Gram matrix. An orthonormal mapping tensor is its own dual, so the same G^\widehat G both extracts and embeds; the extraction and embedding operators of a channel share one array and differ only in their rule. The orthonormalization is irrational in general, so these operators have float coefficients and no printed form, and are used through act or evaluate:

G_hat = get_embedding_operators(n=2, basis="orthonormal")
G_hat_tilde = get_extraction_operators(n=2, basis="orthonormal")

X = act(G_hat_tilde[1, 1], T)
T_1 = act(G_hat[1, 1], X)

print(f"X in the orthonormal basis:\n{X}\n")
print(f"G^ · X, the weight-1 part of T:\n{T_1}")
X in the orthonormal basis:
[-1.41421356  2.82842712 -1.41421356]

G^ · X, the weight-1 part of T:
[[ 0. -1. -2.]
 [ 1.  0. -1.]
 [ 2.  1.  0.]]

Compare with the dual basis: the ICT is 2\sqrt 2 times the dual one, [−1,2,−1][-1, 2, -1], since the two bases scale the mapping tensor differently, but the part of T it carries is the same antisymmetric matrix.

Intrinsic symmetry

A tensor with an intrinsic symmetry carries fewer channels, and symmetry builds the operators of its class. The symmetry is written as index equalities: "ij=ji" is a symmetric rank-2 tensor, "ij=-ji" an antisymmetric one, and "ijkl=jikl=klij" the elasticity tensor.

A symmetric rank-2 tensor has no antisymmetric part, so of the channels of a general rank-2 tensor, weight 1 is gone:

S = (T + T.T) / 2

G_tilde = get_extraction_operators(n=2, symmetry="ij=ji")

print(f"channels (ell, p): {list(G_tilde)}\n")
print(f"G~ of weight 0: {G_tilde[0, 1]}")
print(f"X, the weight-0 ICT of S: {act(G_tilde[0, 1], S)}")
channels (ell, p): [(0, 1), (2, 1)]

G~ of weight 0: +1/3 δ_AB
X, the weight-0 ICT of S: 6.0
References
  1. Wen, M. (2026). Reusable Operators for Irreducible Cartesian Tensor Decomposition and Coupling. arXiv Preprint arXiv:2609.05971. 10.48550/arXiv.2609.05971