|
| 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