Skip to content

Commit ddeaec5

Browse files
committed
Added low-level tensor values tutorial
1 parent 49fda88 commit ddeaec5

2 files changed

Lines changed: 387 additions & 0 deletions

File tree

deps/build.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ files = [
3434
"Low-level API - Geometry" => "geometry_dev.jl",
3535
"Low-level API - ReferenceFEs" => "reffes_dev.jl",
3636
"Low-level API - Poisson equation"=>"poisson_dev_fe.jl",
37+
"Low-level API - TensorValues"=>"tensor_values_dev.jl",
3738
]
3839

3940
Sys.rm(notebooks_dir;recursive=true,force=true)

src/tensor_values_dev.jl

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
# In this tutorial, we will learn
2+
#
3+
# - What the type parameters of `MultiValue{S,T,N,L}` encode and why `L` may be
4+
# strictly less than `prod(S)`
5+
# - How to define a concrete `MultiValue` subtype with constrained storage
6+
# - How to implement the `getindex` and array-conversion interface so that generic
7+
# operations work without further code
8+
# - Which arithmetic, contraction, and linear-algebra operations Gridap provides
9+
# for free once the interface is satisfied
10+
# - How to add type-preserving operations that exploit a type's algebraic structure
11+
# - What the built-in `MultiValue` subtypes are and how their storage layouts differ
12+
13+
using Gridap
14+
using Gridap.TensorValues
15+
using LinearAlgebra: I
16+
using Base: @propagate_inbounds
17+
using Test
18+
19+
#
20+
# ## The `MultiValue` type hierarchy
21+
#
22+
# `Gridap.TensorValues` revolves around the abstract type
23+
#
24+
# ```julia
25+
# MultiValue{S, T, N, L} <: Number
26+
# ```
27+
#
28+
# Its four type parameters mirror those of `StaticArrays.SArray`:
29+
#
30+
# | Parameter | Meaning | Example |
31+
# |-----------|---------|---------|
32+
# | `S` | `Tuple` type encoding the full tensor shape | `Tuple{3,3}` for a 3×3 matrix |
33+
# | `T` | scalar component type | `Float64` |
34+
# | `N` | tensor order (length of `S`) | `2` |
35+
# | `L` | number of **stored** components | ≤ `prod(S)` |
36+
#
37+
# The crucial design freedom is `L ≤ prod(S)`. A `TensorValue{3,3}` stores all 9
38+
# entries; a `SymTensorValue{3}` stores only the 6 upper-triangle entries and
39+
# reconstructs the lower triangle via symmetry on `getindex`; a
40+
# `SkewSymTensorValue{3}` stores 3 entries, recovers the diagonal (zero) and
41+
# lower triangle (negation) at index time.
42+
#
43+
# Subtypes of `MultiValue` subtype `Number`, not `AbstractArray`. This makes
44+
# them behave as scalars under Julia's broadcasting: an array of `VectorValue`s
45+
# participates in the same broadcast machinery as an array of `Float64`s.
46+
#
47+
# ## Implementing a custom `CirculantValue` tensor type
48+
#
49+
# A **circulant matrix** is fully determined by its first row
50+
# $c = (c_1, c_2, \ldots, c_D)$. Every subsequent row is a cyclic shift:
51+
#
52+
# ```math
53+
# A_{ij} = c_{\;\mathrm{mod}_1(j - i + 1,\; D)}
54+
# ```
55+
#
56+
# For $D = 3$:
57+
#
58+
# ```math
59+
# A = \begin{pmatrix} c_1 & c_2 & c_3 \\ c_3 & c_1 & c_2 \\ c_2 & c_3 & c_1 \end{pmatrix}
60+
# ```
61+
#
62+
# A $D \times D$ circulant is a `Tuple{D,D}`-shaped second-order tensor whose
63+
# $D^2$ entries depend on only $D$ independent values, so `L = D`. Circulant
64+
# matrices arise in periodic-boundary convolution operators and are the matrices
65+
# simultaneously diagonalised by the discrete Fourier transform.
66+
67+
struct CirculantValue{D,T} <: MultiValue{Tuple{D,D},T,2,D}
68+
data::NTuple{D,T}
69+
function CirculantValue{D,T}(data::NTuple{D,T}) where {D,T}
70+
new{D,T}(data)
71+
end
72+
end
73+
74+
# The `.data` field holds the first row — the $D$ independent components.
75+
# Gridap's generic machinery **assumes this field exists** and treats it as the
76+
# canonical independent-component vector, so `get_indep_components` and `Tuple`
77+
# already work correctly without any override.
78+
#
79+
# ### Constructors
80+
#
81+
# We follow the same pattern as `VectorValue` and `SymTensorValue`: a primary
82+
# `NTuple` form, a promoted `Tuple` form, and a vararg convenience form.
83+
84+
CirculantValue(data::NTuple{D,T}) where {D,T} = CirculantValue{D,T}(data)
85+
CirculantValue{D}(data::NTuple{D,T}) where {D,T} = CirculantValue{D,T}(data)
86+
CirculantValue{D,T1}(data::NTuple{D,T2}) where {D,T1,T2} = CirculantValue{D,T1}(NTuple{D,T1}(data))
87+
88+
CirculantValue(data::Tuple) = CirculantValue(promote(data...))
89+
CirculantValue{D}(data::Tuple) where {D} = CirculantValue{D}(promote(data...))
90+
CirculantValue{D,T}(data::Tuple) where {D,T} = CirculantValue{D,T}(NTuple{D,T}(data))
91+
92+
CirculantValue(data::Number...) = CirculantValue(data)
93+
CirculantValue{D}(data::Number...) where {D} = CirculantValue{D}(data)
94+
CirculantValue{D,T}(data::Number...) where {D,T} = CirculantValue{D,T}(data)
95+
96+
# ### Completing the mandatory interface
97+
#
98+
# Three methods form the non-negotiable minimum; without any one of them,
99+
# core generic operations silently break.
100+
101+
import Gridap.TensorValues: change_eltype, num_indep_components
102+
103+
# **`change_eltype`** — returns the parameterised type with a different scalar
104+
# component type. The generic fallback `change_eltype(::Type{<:Number>, T) = T`
105+
# would return `Float64` instead of `CirculantValue{D,Float64}`, making scalar
106+
# multiplication (`2.0 * a`) crash when it tries `Float64((2.0, 4.0, 6.0))`.
107+
108+
change_eltype(::Type{<:CirculantValue{D}}, ::Type{T2}) where {D,T2} = CirculantValue{D,T2}
109+
110+
# **`num_indep_components`** — the fallback `num_components(V) = prod(S) = D²`
111+
# gives the wrong count, breaking `zero(a)` (which calls
112+
# `V(tfill(zero(T), Val(num_indep_components(V))))` and tries to build a
113+
# `CirculantValue` with D² zeros) and `conj`/`real`/`imag` (which slice
114+
# `Tuple(a)[1:Li]` with `Li = D²` even though `.data` has only D entries).
115+
116+
num_indep_components(::Type{<:CirculantValue{D}}) where {D} = D
117+
118+
# **`get_array`** — many higher-level operations (contraction, determinant,
119+
# eigenvalues, `outer`) delegate to `get_array`, which must return an `SMatrix`.
120+
# We expand all $D^2$ entries from the $D$-element first row. The `ntuple`
121+
# expression is type-stable and produces zero allocations:
122+
123+
import Gridap.TensorValues: get_array
124+
125+
function get_array(arg::CirculantValue{D,T}) where {D,T}
126+
# Expand the D independent entries into D² column-major data, then delegate
127+
# to TensorValue's own get_array (which returns the StaticArrays SMatrix).
128+
data = ntuple(Val(D*D)) do idx
129+
j = (idx - 1) ÷ D + 1 # 1-based column index
130+
i = (idx - 1) % D + 1 # 1-based row index
131+
@inbounds arg.data[mod1(j - i + 1, D)]
132+
end
133+
get_array(TensorValue{D,D,T}(data))
134+
end
135+
136+
# ### `getindex`
137+
#
138+
# For scalar two-index access, the circulant formula maps any `(i,j)` to a
139+
# single lookup in the $D$-element `.data` tuple:
140+
141+
@propagate_inbounds function Base.getindex(a::CirculantValue{D}, i::Integer, j::Integer) where {D}
142+
@boundscheck checkbounds(a, i, j)
143+
@inbounds a.data[mod1(j - i + 1, D)]
144+
end
145+
146+
# Confirm the layout for a concrete 3×3 example:
147+
148+
a = CirculantValue(1.0, 2.0, 3.0)
149+
150+
@test a[1,1] == 1.0 && a[1,2] == 2.0 && a[1,3] == 3.0
151+
@test a[2,1] == 3.0 && a[2,2] == 1.0 && a[2,3] == 2.0
152+
@test a[3,1] == 2.0 && a[3,2] == 3.0 && a[3,3] == 1.0
153+
154+
@test Matrix(get_array(a)) == [1.0 2.0 3.0
155+
3.0 1.0 2.0
156+
2.0 3.0 1.0]
157+
158+
# ## Operations provided for free
159+
#
160+
# Because `CirculantValue` satisfies the `MultiValue` interface, Gridap's generic
161+
# implementations for arithmetic, contractions, and linear algebra apply
162+
# immediately — no additional code required.
163+
#
164+
# ### Arithmetic on independent components
165+
#
166+
# Addition, subtraction, and scalar multiplication act on the $D$ stored entries
167+
# and return a new `CirculantValue`. The cost is $O(D)$, not $O(D^2)$.
168+
169+
b = CirculantValue(4.0, 5.0, 6.0)
170+
171+
@test a + b === CirculantValue(5.0, 7.0, 9.0)
172+
@test b - a === CirculantValue(3.0, 3.0, 3.0)
173+
@test 2.0 * a === CirculantValue(2.0, 4.0, 6.0)
174+
@test a / 2.0 === CirculantValue(0.5, 1.0, 1.5)
175+
176+
# `zero` constructs the zero circulant (all-zero first row) via
177+
# `num_indep_components`, and works without any override:
178+
179+
@test iszero(zero(a))
180+
@test zero(CirculantValue{3,Float64}) === CirculantValue(0.0, 0.0, 0.0)
181+
182+
# ### Frobenius inner product and norm
183+
#
184+
# `inner(a, b)` or `a ⊙ b` is a full contraction over all index pairs,
185+
# accessing all $D^2$ entries through `getindex`:
186+
187+
@test a a sum(a[i,j]^2 for i in 1:3, j in 1:3)
188+
@test norm(a) sqrt(a a)
189+
190+
# ### Outer product
191+
#
192+
# `a ⊗ b` yields a fourth-order `HighOrderTensorValue{Tuple{3,3,3,3}}`:
193+
194+
c = a b
195+
@test c isa HighOrderTensorValue
196+
@test c[1,2,1,3] a[1,2] * b[1,3]
197+
198+
# ### Matrix–vector contraction
199+
#
200+
# Contracting `a ⋅ v` against a `VectorValue` performs the matrix–vector product
201+
# and returns a `VectorValue`:
202+
203+
v = VectorValue(1.0, 0.0, 0.0)
204+
w = a v
205+
@test w isa VectorValue{3,Float64}
206+
@test w VectorValue(a[1,1], a[2,1], a[3,1]) # multiplication by the first unit vector extracts the first column
207+
208+
# ### Trace and determinant
209+
#
210+
# Both delegate to `getindex` or `get_array` internally. For a circulant,
211+
# every diagonal entry equals $c_1$, so $\mathrm{tr}(A) = D \cdot c_1$:
212+
213+
@test tr(a) 3 * a.data[1]
214+
@test det(a) det(Matrix(get_array(a)))
215+
216+
# ## Type-preserving extensions
217+
#
218+
# Generic contractions return the most general matching type. Contracting two
219+
# `CirculantValue`s with `⋅` uses `contracted_product`, which returns a
220+
# `TensorValue` — the circulant structure is lost:
221+
222+
ab = a b
223+
@test ab isa TensorValue{3,3,Float64}
224+
225+
# The matrix entries are correct, but all $D^2 = 9$ values are now stored
226+
# instead of $D = 3$. We recover the compact form by exploiting the core
227+
# algebraic property: **the product of two circulants is again circulant**, with
228+
# first row equal to the cyclic convolution of the two input rows:
229+
#
230+
# ```math
231+
# (A B)_{1,n} = \sum_{k=1}^{D} c^a_k \; c^b_{\mathrm{mod}_1(n-k+1,\,D)}
232+
# ```
233+
234+
function circ_mul(a::CirculantValue{D,Ta}, b::CirculantValue{D,Tb}) where {D,Ta,Tb}
235+
T = promote_type(Ta, Tb)
236+
row = ntuple(Val(D)) do n
237+
sum(a.data[k] * b.data[mod1(n - k + 1, D)] for k in 1:D)
238+
end
239+
CirculantValue{D,T}(row)
240+
end
241+
242+
@test circ_mul(a, b) isa CirculantValue{3,Float64}
243+
@test get_array(circ_mul(a, b)) get_array(a) * get_array(b)
244+
245+
# `circ_mul` stores only $D$ values and computes in $O(D^2)$ — the same
246+
# arithmetic as the general product, but with $D$ rather than $D^2$ storage.
247+
#
248+
# ### Transpose
249+
#
250+
# For a circulant with first row $(c_1, c_2, \ldots, c_D)$, the transpose has
251+
# first row $(c_1, c_D, c_{D-1}, \ldots, c_2)$ — position 1 is fixed and
252+
# positions 2 through $D$ are reversed. Gridap's fallback for
253+
# `MultiValue{Tuple{D,D}}` is `@notimplemented`, so we provide a specialisation:
254+
255+
function Base.transpose(a::CirculantValue{D,T}) where {D,T}
256+
data = ntuple(Val(D)) do k
257+
k == 1 ? a.data[1] : a.data[D - k + 2]
258+
end
259+
CirculantValue{D,T}(data)
260+
end
261+
262+
at = transpose(a)
263+
@test at isa CirculantValue{3,Float64}
264+
@test get_array(at) transpose(Matrix(get_array(a)))
265+
266+
# A circulant is symmetric iff its first row is a palindrome:
267+
268+
s = CirculantValue(1.0, 2.0, 2.0)
269+
@test transpose(s) == s
270+
271+
# ### Mixed-precision arithmetic (optional extension)
272+
#
273+
# By default, `promote_rule` for two different-T `MultiValue`s returns `Union{}`
274+
# — meaning Julia raises a method error rather than trying to promote. Adding
275+
# `promote_rule` and a matching `convert` unlocks operations like
276+
# `CirculantValue{3,Float64} + CirculantValue{3,Int64}`.
277+
#
278+
# The `convert` must be explicit: the generic `convert(::Type{T<:Number}, x::Number) = T(x)`
279+
# would call `CirculantValue{3,Float64}(x::CirculantValue{3,Int64})`, which
280+
# matches the vararg constructor with a 1-element argument and then fails
281+
# constructing `NTuple{3,Float64}` from a 1-element tuple.
282+
283+
Base.promote_rule(::Type{<:CirculantValue{D,Ta}}, ::Type{<:CirculantValue{D,Tb}}) where {D,Ta,Tb} =
284+
CirculantValue{D, promote_type(Ta,Tb)}
285+
286+
Base.convert(::Type{<:CirculantValue{D,T}}, arg::CirculantValue{D}) where {D,T} =
287+
CirculantValue{D,T}(Tuple(arg))
288+
Base.convert(::Type{<:CirculantValue{D,T}}, arg::CirculantValue{D,T}) where {D,T} = arg
289+
290+
@test a + CirculantValue(1, 0, 0) === CirculantValue(2.0, 2.0, 3.0)
291+
292+
# ### Identity
293+
#
294+
# The $D \times D$ identity matrix is circulant with first row $(1, 0, \ldots, 0)$.
295+
# Overriding `one` lets generic code that calls `one(a)` or `one(CirculantValue{D,T})`
296+
# get the compact representation rather than an error:
297+
298+
function Base.one(::Type{V}) where {D, T, V <: CirculantValue{D,T}}
299+
CirculantValue{D,T}(ntuple(i -> i == 1 ? one(T) : zero(T), Val(D)))
300+
end
301+
Base.one(a::CirculantValue) = one(typeof(a))
302+
303+
id = one(CirculantValue{3,Float64})
304+
@test get_array(id) Matrix(1.0*I, 3, 3)
305+
@test circ_mul(a, id) == a
306+
307+
# ## Built-in `MultiValue` types
308+
#
309+
# Gridap ships nine concrete subtypes covering the tensors most common in
310+
# continuum mechanics and electromagnetics.
311+
#
312+
# | Type | Shape `S` | `L` | Storage |
313+
# |------|-----------|-----|---------|
314+
# | `VectorValue{D,T}` | `Tuple{D}` | `D` | all D components |
315+
# | `TensorValue{D1,D2,T}` | `Tuple{D1,D2}` | `D1·D2` | all, column-major |
316+
# | `SymTensorValue{D,T}` | `Tuple{D,D}` | `D(D+1)/2` | upper triangle, row ≤ col |
317+
# | `SymTracelessTensorValue{D,T}` | `Tuple{D,D}` | `D(D+1)/2 − 1` | upper triangle; last diagonal recovered as `−(sum of others)` |
318+
# | `SkewSymTensorValue{D,T}` | `Tuple{D,D}` | `D(D−1)/2` | strict upper triangle; diagonal zero, lower triangle negated |
319+
# | `ThirdOrderTensorValue{D1,D2,D3,T}` | `Tuple{D1,D2,D3}` | `D1·D2·D3` | all, column-major |
320+
# | `HighOrderTensorValue{S,T,N}` | any `S` | `prod(S)` | all, `SArray` |
321+
# | `SymFourthOrderTensorValue{D,T}` | `Tuple{D,D,D,D}` | `(D(D+1)/2)²` | both index pairs symmetric: `ijkl=jikl=ijlk` |
322+
#
323+
# The independent-component counts for $D = 3$:
324+
325+
@test num_indep_components(VectorValue{3,Float64}) == 3
326+
@test num_indep_components(TensorValue{3,3,Float64}) == 9
327+
@test num_indep_components(SymTensorValue{3,Float64}) == 6
328+
@test num_indep_components(SymTracelessTensorValue{3,Float64}) == 5
329+
@test num_indep_components(SkewSymTensorValue{3,Float64}) == 3
330+
@test num_indep_components(ThirdOrderTensorValue{2,2,2,Float64}) == 8
331+
@test num_indep_components(SymFourthOrderTensorValue{2,Float64}) == 9
332+
333+
# **`VectorValue{D,T}`** — a plain $D$-vector.
334+
335+
u = VectorValue(1.0, 2.0, 3.0)
336+
@test u u 14.0
337+
338+
# **`TensorValue{D1,D2,T}`** — a general $D_1 \times D_2$ matrix with no symmetry
339+
# imposed. The constructor fills entries in column-major order.
340+
341+
F = TensorValue(1.0, 0.0, 0.0, # column 1
342+
0.0, 2.0, 0.0, # column 2
343+
0.0, 0.0, 3.0) # column 3
344+
@test F[1,1] == 1.0 && F[2,2] == 2.0 && F[3,3] == 3.0
345+
@test det(F) 6.0
346+
347+
# **`SymTensorValue{D,T}`** — symmetric second-order tensor; stores only the
348+
# upper triangle. Entry (i,j) with i > j is recovered as (j,i).
349+
350+
σ = SymTensorValue(1.0, 0.5, 0.0, # row 1: σ₁₁, σ₁₂, σ₁₃
351+
2.0, 0.0, # σ₂₂, σ₂₃
352+
3.0) # σ₃₃
353+
@test σ[2,1] == σ[1,2] == 0.5
354+
355+
# **`SkewSymTensorValue{D,T}`** — anti-symmetric; diagonal always zero, lower
356+
# triangle recovered as the negation of the upper triangle.
357+
358+
Ω = SkewSymTensorValue(0.1, 0.2, 0.3) # upper-triangle entries for D=3
359+
@test Ω[2,1] == -Ω[1,2]
360+
@test iszero(Ω[1,1])
361+
362+
# **`SymTracelessTensorValue{D,T}`** (alias `QTensorValue`) — symmetric and
363+
# trace-free; used for liquid-crystal order-parameter tensors. The final
364+
# diagonal entry is not stored; it is recovered as minus the sum of the others.
365+
366+
Q = SymTracelessTensorValue(0.3, 0.0, -0.1, 0.0, 0.0) # 5 entries for D=3
367+
@test tr(Q) 0.0
368+
369+
# **`ThirdOrderTensorValue{D1,D2,D3,T}`** — third-order tensor, alias for
370+
# `HighOrderTensorValue{Tuple{D1,D2,D3}}`, no symmetry assumed.
371+
372+
G = ThirdOrderTensorValue{2,2,2,Float64}(ntuple(i -> Float64(i), 8))
373+
@test size(G) == (2, 2, 2)
374+
375+
# **`SymFourthOrderTensorValue{D,T}`** — fourth-order tensor with the two minor
376+
# symmetries $\mathbb{C}_{ijkl} = \mathbb{C}_{jikl} = \mathbb{C}_{ijlk}$ that
377+
# characterise the elasticity tensor.
378+
379+
𝕔 = SymFourthOrderTensorValue{2,Float64}(ntuple(i -> Float64(i), 9))
380+
@test 𝕔[1,2,1,1] == 𝕔[2,1,1,1] # minor symmetry ij↔ji
381+
382+
# Contracting the elasticity tensor with a symmetric strain gives a stress:
383+
384+
ε = SymTensorValue(0.1, 0.0, 0.2) # 2×2 symmetric strain
385+
τ = 𝕔 ² ε # double contraction: stress
386+
@test τ isa SymTensorValue{2,Float64}

0 commit comments

Comments
 (0)