Tangent stiffness matrix and its product by a vector#
This page documents how the internal force vector and its exact Jacobian, the tangent stiffness matrix, are implemented:
Tangent stiffness of the CLPT models with Donnell kinematics derives \(\{F_{int}\}\) and \([K_T]\) of the models
plate_clpt_donnellandcylshell_clpt_donnelland relates every term to the function of the implementation that evaluates it, including the two integration paths, the grouping of the terms betweenShell.calc_kC()andShell.calc_kG(), and the multi-domain assembly;Product of the tangent stiffness matrix by a vector derives the product \([K_T(c)] \{v\}\) for all the geometrically non-linear models,
Shell.calc_kTv(), evaluated without assembling \([K_T]\).
Tangent stiffness of the CLPT models with Donnell kinematics#
This section documents the internal force vector and the tangent stiffness
matrix as they are implemented in the models plate_clpt_donnell and
cylshell_clpt_donnell, both based on the classical laminated plate
theory (CLPT) with the Donnell-Mushtari-Vlasov (von Kármán) kinematics.
Each equation is related to the function of the implementation that
evaluates it, such that the reader can move between the equations and the
code in both directions. The objective is to show that each term of the
tangent stiffness matrix follows from the exact derivative of the internal
force vector,
which is what delivers the quadratic convergence of the Newton-Raphson iterations \(\boldsymbol{K}_T(\bar{\boldsymbol{c}}_k) \Delta \bar{\boldsymbol{c}} = \boldsymbol{F}_{ext} - \boldsymbol{F}_{int}(\bar{\boldsymbol{c}}_k)\), see Non-linear static analysis with the Newton-Raphson method. In the notation of Product of the tangent stiffness matrix by a vector, \([B(\beta)] = \boldsymbol{B}_L + \boldsymbol{B}_{NL}\), \([G] = \boldsymbol{G}\) and \([F] = \boldsymbol{C}\).
The tangent stiffness matrix is classically decomposed as
with the constitutive matrices \(\boldsymbol{K}_0\) to \(\boldsymbol{K}_{LL}\) and the geometric stiffness \(\boldsymbol{K}_G\) of the membrane stress \(\boldsymbol{N}\), see (16) and (14). The implementation is organised around a three-way split:
whose relation to the grouping of the implementation, stated in a comment
of fkC_num in plate_clpt_donnell_num.pyx and in its cylindrical
twin, is established in Which stress enters the geometric stiffness, and the degree of homogeneity and tabulated
in Symbols and implementation variables. The correspondence is not a
renaming only: the boundary between the constitutive and the geometric
contributions of (3) sits at a different place from the
one of (2), and
Where the naming and the code do not line up says exactly where.
The two implementation paths#
Each model contributes two modules, registered in panels/modelDB.py
under the keys matrices and matrices_num:
The closed-form path,
matrices, integrates the full domain analytically. The integrals of products of the approximation functions are evaluated in closed form, and the resulting matrices are constant with respect to \(\bar{\boldsymbol{c}}\). It providesfk0andfkG0ofplate_clpt_donnell.pyxandcylshell_clpt_donnell.pyx.The numerically integrated path,
matrices_num, applies a Gauss-Legendre rule over \(n_x \times n_y\) points, and is the only path that can honour a displacement state \(\bar{\boldsymbol{c}}\), a laminate stiffness varying over the domain, or a partial integration domain. It providesfkC_num,fkG_numandcalc_fintofplate_clpt_donnell_num.pyxandcylshell_clpt_donnell_num.pyx.
The selection is made in Shell.calc_kC() and Shell.calc_kG()
by two flags, analytical_kC and analytical_kG, initialised to
True and cleared by the conditions of the following table.
Condition |
Affects |
|---|---|
|
|
|
|
the argument |
|
|
|
|
the |
Shell.is_partial_domain() reports whether the integration limits
x1, x2, y1, y2 differ from the edges of the shell, \(0\), \(a\) and \(b\),
after Shell.integration_limits() has replaced a limit left as
None by the corresponding edge and has validated \(0 \le x_1 < x_2 \le
a\) and \(0 \le y_1 < y_2 \le b\). Two consequences are worth stating
explicitly:
Shell.calc_kT()calls bothShell.calc_kC()andShell.calc_kG()withNLgeom=True. The tangent stiffness matrix therefore always takes the numerically integrated path, and the closed-form path never participates in a non-linear analysis.Conversely, the closed-form path is reached only for \(\boldsymbol{K}_{C0}\) and for \(\boldsymbol{K}_G\) evaluated at a uniform prescribed membrane stress state, i.e. for a linear static analysis and for the classical linear buckling problem \((\boldsymbol{K}_0 + \lambda \boldsymbol{K}_G) \boldsymbol{q} = \boldsymbol{0}\), over a full domain with a constant laminate.
Approximation functions#
The three mid-surface displacements are approximated using the hierarchic
polynomials \(P_i\) of Bardell, see Bardell’s Hierarchical Functions, evaluated by
f, fp and fpp of panels/core/include/bardell_functions.hpp,
implemented in panels/core/src/bardell_functions.cpp, with the Python
wrappers calc_f, calc_fp and calc_fpp of
panels/bardell.pyx. Independent boundary flags are used for each
displacement component and for each direction:
where the superscript in \(P_i^u(\xi)\) indicates that the flags
\(\delta_{t1}\), \(\delta_{r1}\), \(\delta_{t2}\), \(\delta_{r2}\) of the first
four terms refer to the boundary conditions of \(u\) at \(\xi = -1\) and \(\xi =
+1\), and similarly for \(P_j^u(\eta)\), \(P_i^v(\xi)\), and so forth. Only the
first four terms of vec_f in bardell_functions.cpp carry these
flags; the remaining terms vanish together with their first derivative at
both ends, which is what makes the set hierarchic. The implementation
provides 30 terms. The natural coordinates are:
where, for the cylindrical shell, \(y = r\theta\) is the circumferential arc length and \(b\) the corresponding arc length of the panel. Derivatives with respect to the physical coordinates follow from the chain rule:
which is the origin of every factor (2/a) and (2/b) appearing in
the kernels, for instance in the strains of calc_fint. In vector
notation:
with the alternated ordering of the degrees of freedom:
such that, using zero-based indices \(i\) and \(j\), the coefficients
\(c_{ij}^u\), \(c_{ij}^v\) and \(c_{ij}^w\) occupy the positions \(3(jm+i)\),
\(3(jm+i)+1\) and \(3(jm+i)+2\) of \(\bar{\boldsymbol{c}}\). This is the mapping
col = col0 + DOF*(j*m + i) with DOF = 3 of the kernels, and the
size of \(\bar{\boldsymbol{c}}\) is \(3mn\), see Shell.get_size(). The
only non-zero components of \(\boldsymbol{S}^u\), \(\boldsymbol{S}^v\) and
\(\boldsymbol{S}^w\) are:
and, for instance:
Throughout the kernels, the pair of indices \((i,j)\) names the row term,
denoted \(A\), and \((k,l)\) names the column term, denoted \(B\); the variables
fAw, gAweta, fBwxi and so forth follow that convention, read
from the tables of the approximation functions evaluated once per
integration point, see Approximation functions tabulated at the integration points. Only the upper triangle is
assembled, the lower one being recovered by finalize_symmetric_matrix
of structsolve, called by Shell.calc_kC() and
Shell.calc_kG() with finalize=True, which is why every kernel
skips the entries with row > col.
Kinematics as implemented#
The Donnell kinematics of a cylindrical shell neglect the terms of the
Sanders-Koiter kinematics that bring the in-plane displacements into the
rotations and the curvatures, see the model cylshell_clpt_sanders, and
the plate kinematics of von Kármán are recovered by making \(1/r = 0\):
(5) is the strain state assembled term by term in
calc_fint of cylshell_clpt_donnell_num.pyx, where the hoop term
appears as 1./r*cs[col+2]*fAw*gAw and the von Kármán contributions as
the products with wxi and weta. The plate, calc_fint of
plate_clpt_donnell_num.pyx, differs from it in exactly one place, the
absence of that hoop term in eyy; the five remaining strain components
are character for character identical. The same single-term difference
holds in fkG_num.
On presenting the plate as the limit of the cylinder#
The cylinder is the general case and the plate is its \(1/r \to 0\) limit, but this statement must be read as a limit and not as a substitution, for two reasons found in the code.
First, the plate is a separate implementation, not a specialisation
evaluated at a particular radius: panels/modelDB.py binds each model to
its own pair of modules, and no plate quantity is ever obtained by calling
a cylinder kernel.
Second, the limit is \(r \to \infty\) and not \(r = 0\). The cylindrical
kernels contain \(1/r\) and \(1/r^2\) in denominators, for instance the term
0.25*A22*a*b*fAwfBw*gAwgBw/(r*r) of fk0 in
cylshell_clpt_donnell.pyx and the corresponding terms of fkC_num.
Evaluating them at \(r = 0\) produces infinities, which would only surface
later, as an assertion inside finalize_symmetric_matrix rejecting the
matrix. The radius is therefore validated before any kernel is called.
Each model declares in panels/modelDB.py whether it needs one, through
the key requires_r, set for the cylinders and cleared for the plates.
Shell._check_r() raises a ValueError naming the model when
requires_r is set and \(r\) is None or \(r \leq 0\), and it is called
by the methods that reach the kernels, e.g. Shell.calc_kC(),
Shell.calc_kG(), Shell.calc_kM(), Shell.calc_kA() and
Shell.calc_fint(). A shell declared with
model='cylshell_clpt_donnell' and r=None is thus rejected with a
message pointing at plate_clpt_donnell, rather than being evaluated at a
radius that is the opposite of the plate limit.
For the plate model, whose kernels never read r, an unset radius is
normalised to zero, the value the remaining code compares against. There
\(r = 0\) is not a radius but a marker for the absence of one.
Numerically, the convergence of the cylinder towards the plate is first order in \(1/r\), as expected from (5), where the hoop term enters linearly. Comparing the two implementations on the same \(\bar{\boldsymbol{c}}\), with \(a = 0.3\) m, \(b = 0.2\) m, \(m = n = 4\) and an unsymmetric laminate, gives the relative differences:
\(r\) [m] |
\(\|\boldsymbol{K}_C^{cyl} - \boldsymbol{K}_C^{plate}\| / \|\boldsymbol{K}_C^{plate}\|\) |
\(\|\boldsymbol{F}_{int}^{cyl} - \boldsymbol{F}_{int}^{plate}\| / \|\boldsymbol{F}_{int}^{plate}\|\) |
|---|---|---|
\(10^{0}\) |
\(3.58 \times 10^{-3}\) |
\(2.26 \times 10^{-2}\) |
\(10^{2}\) |
\(3.58 \times 10^{-5}\) |
\(2.26 \times 10^{-4}\) |
\(10^{4}\) |
\(3.58 \times 10^{-7}\) |
\(2.26 \times 10^{-6}\) |
\(10^{6}\) |
\(3.58 \times 10^{-9}\) |
\(2.26 \times 10^{-8}\) |
\(10^{8}\) |
\(3.58 \times 10^{-11}\) |
\(2.26 \times 10^{-10}\) |
In the equations that follow, the cylinder is written as the general case and the plate is recovered by dropping the terms carrying \(1/r\), which is a statement about the equations and not an instruction about the code.
Constitutive relation#
The generalised strain and stress vectors are related by the laminate stiffness:
with \(\boldsymbol{N} = \{N_{xx} \quad N_{yy} \quad N_{xy}\}^\top\) and
\(\boldsymbol{M} = \{M_{xx} \quad M_{yy} \quad M_{xy}\}^\top\).
(6) is applied in calc_fint of both models. The
\(6 \times 6\) array \(\boldsymbol{C}\) is the argument named Finput of
every kernel, read from the attribute Shell.ABDnxny when it is set,
else from the read-only property Shell.ABD, calculated by
Shell._get_lam_ABD() from the laminate Shell.lam. When Finput
has the shape \((n_x, n_y, 6, 6)\) it is interpreted as one constitutive
matrix per integration point, which is how variable stiffness laminates
enter, see Shell.integration_points(); a \((6, 6)\) array is used for
the whole domain instead. Everything that follows holds pointwise, so a
\(\boldsymbol{C}\) varying over the domain changes nothing in the derivation.
Linear and non-linear differentiation operators#
The linear part of (5) is written as \(\hat{\boldsymbol{\varepsilon}}_L = \boldsymbol{B}_L \bar{\boldsymbol{c}}\), with:
(7) is exactly the set of six accumulators of the strains in
fkG_num, which is the one place in the code where the linear operator
appears on its own.
For the non-linear part, it is convenient to define the operator \(\boldsymbol{G}\) that extracts the slopes of the deflection:
(8) is the Donnell-Mushtari-Vlasov specialisation of the
rotation operators: the Sanders-Koiter kinematics add the terms
\(\boldsymbol{S}^u/R_1\) and \(\boldsymbol{S}^v/R_2\) of the in-plane
displacements, with the principal radii of curvature \(R_1\) and \(R_2\). The
code confirms this: neither fkG_num nor fkG0 contains
\(\boldsymbol{S}^v\), and both allocate \(1 \cdot m^2 n^2\) entries instead of
the \(9 \cdot m^2 n^2\) of fkC_num (fdim), because only the \(w\)–\(w\)
block is populated. This holds for the cylinder as well as for the plate:
the \(1/r\) term present in \(\boldsymbol{B}_L\) has no counterpart in
\(\boldsymbol{G}\).
In the kernels, the slopes are accumulated in the natural coordinates as
wxi and weta, in fkC_num and in calc_fint, and converted as:
With (8), the non-linear differentiation operator becomes:
which gives the strain and its variation:
The factor \(\frac{1}{2}\) of (10) applies to the shear
row as well, and this is visible in the code as an asymmetry that is easy
to mistake for an error: in the strains of calc_fint the two normal
strains carry an explicit 0.5 whereas the shear strain does not. The
reason is that the shear row of \(\boldsymbol{B}_{NL}\) already contains two
terms, so \(\frac{1}{2}\boldsymbol{B}_{NL}\bar{\boldsymbol{c}}\) evaluates to
\(\frac{1}{2}(2 w_{,x} w_{,y}) = w_{,x}w_{,y}\), which is what the single
unscaled product in the code produces. Evaluating the non-linear shear
strain as \(\boldsymbol{B}_{NL}\bar{\boldsymbol{c}}\) instead would give \(2
w_{,x} w_{,y}\). That error does not show in \(\boldsymbol{B}_{NL}\) itself,
but it corrupts the stress state, and therefore the internal force vector
and the geometric stiffness matrix, leaving a tangent stiffness matrix that
is no longer the derivative of the internal force vector.
Internal force vector#
The internal force vector is the derivative of the strain energy, \(U = \frac{1}{2} \int_{\mathcal{A}} \hat{\boldsymbol{\varepsilon}}^\top \boldsymbol{C} \hat{\boldsymbol{\varepsilon}} \, d\mathcal{A}\), integrated over the mid-surface, with respect to \(\bar{\boldsymbol{c}}\):
which gives, for the three degrees of freedom associated with the term \((i,j)\):
(12) is the update of fint in calc_fint of
cylshell_clpt_donnell_num.pyx. The hoop contribution \(N_{yy} S^w / r\)
appears there as Nyy*(fAw*gAw/r + ...), and the plate form, in
plate_clpt_donnell_num.pyx, is the same expression without it. Note
that \(\boldsymbol{B}_L\) contributes the hoop term and the three curvature
terms to the \(w\) row, whereas \(\boldsymbol{B}_{NL}\) contributes the three
products of membrane forces with slopes; the \(u\) and \(v\) rows receive no
contribution from \(\boldsymbol{B}_{NL}\), which is why they contain only
\(\boldsymbol{N}\) and no slope.
Tangent stiffness matrix#
Differentiating (11) with respect to \(\bar{\boldsymbol{c}}\), (1):
The first integral uses \(\partial \hat{\boldsymbol{\sigma}} / \partial \bar{\boldsymbol{c}} = \boldsymbol{C}\,(\boldsymbol{B}_L + \boldsymbol{B}_{NL})\), which follows from (10) together with \(\partial (\boldsymbol{B}_{NL}\bar{\boldsymbol{c}}) / \partial \bar{\boldsymbol{c}} = 2\boldsymbol{B}_{NL}\), itself a consequence of \(\boldsymbol{B}_{NL}\) being homogeneous of degree one in \(\bar{\boldsymbol{c}}\). The second integral is evaluated by writing \(\boldsymbol{B}_{NL}^\top \hat{\boldsymbol{\sigma}}\) explicitly with (9):
and differentiating with respect to \(\bar{\boldsymbol{c}}\) at a fixed stress state, using (8):
(14) is the geometric stiffness of the Donnell-Mushtari-Vlasov kinematics, and it only has non-zero entries in the \(w\)–\(w\) blocks. For the term pairs \(A = (i,j)\) and \(B = (k,l)\):
(15) is transcribed literally in fkG_num of
cylshell_clpt_donnell_num.pyx, where the four products of shape
functions appear in the same order. The plate writes the same expression in
an algebraically expanded form; the two are identical after collecting the
powers of \(a\) and \(b\).
Which stress enters the geometric stiffness, and the degree of homogeneity#
The first integral of (13) expands into the four classical groups of (2):
The membrane stress of (14) is also split, according to the strain that produces it:
where \(\boldsymbol{N}^0\) is an optional prescribed pre-stress, used for
instance to superpose combined load cases, see c_cte and Nxx_cte,
Nyy_cte, Nxy_cte in Shell.calc_kC(), and \(\boldsymbol{B}\) does
not contribute to \(\boldsymbol{N}_{NL}\) because \(\boldsymbol{\varepsilon}^{(1)}\)
is linear for the CLPT. In the code, \(\boldsymbol{N}^0 + \boldsymbol{N}_L\)
is computed in fkG_num, from the arguments Nxx0, Nyy0, Nxy0 and from
the linear strains of (7); and \(\boldsymbol{N}_{NL}\) is
computed separately, in fkC_num, as NxxNL, NyyNL, NxyNL, where the
0.5 factors of (17) are explicit. Since
(14) is linear in \(\boldsymbol{N}\):
With respect to \(\bar{\boldsymbol{c}}\): \(\boldsymbol{K}_0\) is constant; \(\boldsymbol{K}_{0L}\), \(\boldsymbol{K}_{L0}\) and \(\boldsymbol{K}_G(\boldsymbol{N}_L)\) are homogeneous of degree one; and \(\boldsymbol{K}_{LL}\) and \(\boldsymbol{K}_{GNL}\) are homogeneous of degree two. The implementation groups them as:
(19) is the statement written as a comment in fkC_num
of both models, and the summation \(\boldsymbol{K}_T = \boldsymbol{K}_C +
\boldsymbol{K}_G\) happens in Shell.calc_kT(). The four constitutive
groups of (16) are not assembled separately: the kernel
evaluates the whole product \((\boldsymbol{B}_L + \boldsymbol{B}_{NL})^\top
\boldsymbol{C} (\boldsymbol{B}_L + \boldsymbol{B}_{NL})\) in expanded
symbolic form, in nine blocks, one per pair of displacements, and
\(\boldsymbol{K}_{GNL}\) is then added on top of the \(w\)–\(w\) block only,
under the comment # KGNL.
Why \(\boldsymbol{K}_{GNL}\) sits where it does. Although \(\boldsymbol{K}_{GNL}\) is geometric in nature, being a \(\boldsymbol{G}^\top \tilde{\boldsymbol{N}} \boldsymbol{G}\) product, it is deliberately collected together with the constitutive terms so that \(\boldsymbol{K}_G(\bar{\boldsymbol{c}})\) stays homogeneous of degree one:
The linear buckling problem \((\boldsymbol{K}_0 + \lambda
\boldsymbol{K}_G(\bar{\boldsymbol{c}}_0)) \boldsymbol{q} = \boldsymbol{0}\)
needs this property, because there the load multiplier \(\lambda\) scales a
stress state obtained from a linear fundamental state
\(\bar{\boldsymbol{c}}_0\), and a \(\boldsymbol{K}_G\) containing a degree-two
term would make \(\lambda\) scale that state non-linearly, such that the
problem would no longer be a linear eigenvalue problem. With the grouping of
(19), both requirements are met at once:
\(\boldsymbol{K}_T\) is the exact Jacobian of \(\boldsymbol{F}_{int}\), so the
Newton-Raphson iterations converge quadratically, and \(\boldsymbol{K}_G\)
remains usable as the geometric stiffness of a linear buckling analysis. The
consequence is that fkG_num has no switch for \(\boldsymbol{N}_{NL}\) at
all: its signature takes no NLgeom argument, and its docstring states
why. Shell.calc_kG() still accepts NLgeom, but only to force the
numerically integrated path of The two implementation paths; it does
not change the value of \(\boldsymbol{K}_G\).
Mapping onto the three-way split#
With (19) established, the three-way split of (3) is:
i.e. \(\boldsymbol{K}_{C0}\) is everything that fkC_num produces at
\(\bar{\boldsymbol{c}} = \boldsymbol{0}\), \(\boldsymbol{K}_{CNL}\) is
everything else that fkC_num produces, and \(\boldsymbol{K}_G\) is what
fkG_num produces. The partition is clean, but only under that reading of
\(\boldsymbol{K}_{CNL}\), which is not the obvious one;
Where the naming and the code do not line up states the caveat.
There is no separate code path for \(\boldsymbol{K}_{C0}\) and
\(\boldsymbol{K}_{CNL}\): fkC_num returns their sum. \(\boldsymbol{K}_{C0}\)
alone is obtained either from the closed-form fk0 or by calling
fkC_num with \(\bar{\boldsymbol{c}} = \boldsymbol{0}\) or with
NLgeom=0, both of which make wxi and weta vanish, since their
accumulation is guarded by if NLgeom == 1. With \(w_{,x} = w_{,y} = 0\)
one has \(\boldsymbol{B}_{NL} = \boldsymbol{0}\) and \(\boldsymbol{N}_{NL} =
\boldsymbol{0}\), so \(\boldsymbol{K}_{CNL}\) vanishes identically, as a
comment in fkC_num notes.
The closed-form path#
The closed-form kernels evaluate the same \(\boldsymbol{K}_0\) and \(\boldsymbol{K}_G\), over the full domain and for a constant laminate, by separating the two directions. Every entry of (16) restricted to \(\boldsymbol{B}_L\) is a sum of products of the form
and the two one-dimensional integrals are evaluated analytically by the six
functions integral_ff, integral_ffp, integral_ffpp,
integral_fpfp, integral_fpfpp and integral_fppfpp of
panels/core/include/bardell.hpp, declared at the top of
plate_clpt_donnell.pyx and wrapped for Python as calc_integral_ff
and so forth in panels/bardell.pyx. They are hoisted out of the inner
loops, the \(\xi\) integrals inside the \(i\), \(k\) loops and the \(\eta\)
integrals inside the \(j\), \(l\) loops, which is what makes this path cheap: no
loop over integration points exists at all.
As an example, the first entry of \(\boldsymbol{K}_0\) in fk0 of
plate_clpt_donnell.pyx reads
k0v[c] += A11*b*fAuxifBuxi*gAugBu/a + A16*(fAufBuxi*gAuetagBu
+ fAuxifBu*gAugBueta) + A66*a*fAufBu*gAuetagBueta/b
which is the \(u\)–\(u\) entry of \(\int \boldsymbol{B}_L^\top \boldsymbol{C}
\boldsymbol{B}_L \, d\mathcal{A}\), the factor \(b/a\) following from
(21) with \(p = q = 1\). The corresponding cylindrical
entry, in fk0 of cylshell_clpt_donnell.pyx, is identical, the two
implementations of \(\boldsymbol{K}_0\) differing only by the five entries
carrying \(1/r\) and \(1/r^2\) that (7) introduces through
\(\boldsymbol{S}^w/r\).
Two properties of this path bound its use:
fk0takes no \(\bar{\boldsymbol{c}}\) and no constitutive matrix per point; its signature isfk0(shell, size, row0, col0). It can only ever produce \(\boldsymbol{K}_{C0}\).fkG0takes the three membrane stress resultants as scalars,fkG0(Nxx, Nyy, Nxy, shell, size, row0, col0), so it evaluates \(\boldsymbol{K}_G(\boldsymbol{N}^0)\) for a membrane state uniform over the domain. The bodies offkG0are identical for the plate and for the cylinder, consistent with \(\boldsymbol{G}\) of (8) carrying no \(1/r\); the only entry assembled is character for character the same in both modules.
The geometric stiffness must therefore be integrated with fkG_num, and
not fkG0, whenever the pre-buckling resultants are not uniform, e.g.
near the edges of a cylinder whose fundamental state is not a pure membrane
state: fkG0 cannot represent it.
Numerical integration#
All integrals of the numerically integrated path are computed in natural
coordinates using the Gauss-Legendre quadrature, with \(n_x\) and \(n_y\)
points obtained from scipy.special.roots_legendre:
For integration over a sub-domain \(x_1 \le x \le x_2\) and \(y_1 \le y \le
y_2\), the factor \(ab/4\) becomes \((x_2-x_1)(y_2-y_1)/4\), computed as
intx*inty, and the Gauss points are mapped into the corresponding
interval of \(\xi\) and \(\eta\), the interval itself being given by the
physical limits returned by Shell.integration_limits() and mapped
unconditionally, which gives exactly \(\xi, \eta = \pm 1\) for the full
domain; Shell.integration_points() returns the same points and
weights in physical coordinates. The factor \(1/4\) is not always visible in
the kernels: it is sometimes folded into the symbolic expansion, as in the
entries of fkC_num of plate_clpt_donnell_num.pyx, and sometimes
written out, as weight*(intx*inty/4) in fkG_num of
cylshell_clpt_donnell_num.pyx. Both forms are the same number.
\(\boldsymbol{F}_{int}\) and \(\boldsymbol{K}_T\) must be integrated with the
same quadrature rule, which is why Shell.calc_fint() and
Shell.calc_kT() both default \(n_x\) and \(n_y\) to the attributes nx
and ny of the shell, or to the shape of ABDnxny when it has one
matrix per point. The consistency between them is then exact at the
discrete level, whether or not the quadrature integrates the polynomials
exactly: (13) is the derivative of (11)
pointwise, hence also after applying the same weighted sum to both.
Multi-domain assembly#
For an assembly of panels, each Shell writes its own contribution
into a global matrix of size \(\sum_p 3 m_p n_p\), offset by the attributes
row_start and col_start and passed through as the arguments
row0 and col0 of every kernel. Denoting by \(\boldsymbol{L}_p\) the
Boolean operator that selects the degrees of freedom of panel \(p\) from the
global vector, the assembly is:
(23) is MultiDomain.calc_fint() and
MultiDomain.calc_kT(). Three points are relevant here:
The panel-level blocks enter unchanged.
MultiDomain.calc_kT()callsp.calc_kC(..., NLgeom=True)andp.calc_kG(..., NLgeom=True)for each panel, i.e. exactly the two calls ofShell.calc_kT(), so (19) holds panel by panel.Both are assembled with
finalize=Falseand the symmetrisation is applied once to the sum, because each panel only fills its own upper triangle. The load stiffness of the follower pressure loads, which is unsymmetric, is added after the symmetrisation, see Follower pressure loads: theory.The connection matrix \(\boldsymbol{K}_{C}^{conn}\) is added after the loop, to \(\boldsymbol{K}_T\) and, as \(\boldsymbol{K}_{C}^{conn} \bar{\boldsymbol{c}}\), to \(\boldsymbol{F}_{int}\). Its own derivation is out of the scope of this section, see Exact connections with the null-space method; what matters here is that it is added consistently to both, so that the assembled \(\boldsymbol{K}_T\) remains the derivative of the assembled \(\boldsymbol{F}_{int}\) whenever \(\boldsymbol{K}_{C}^{conn}\) is independent of \(\bar{\boldsymbol{c}}\).
Symbols and implementation variables#
The following tables relate the symbols used herein to the variables of the implementation, those of the plate kernels unless stated, the cylindrical twins using the same names, and map the matrix terms.
Symbol |
Variable |
Where |
|---|---|---|
\(\bar{\boldsymbol{c}}\) |
|
argument of the kernels |
\(m\), \(n\) |
|
attributes of |
\(a\), \(b\), \(r\) |
|
attributes of |
\(P_i^u(\xi)\), \(P_i^{u\prime}(\xi)\) |
|
|
\(P_j^w(\eta)\), \(P_j^{w\prime}\), \(P_j^{w\prime\prime}\) |
|
|
\(P_k^w(\xi)\), \(P_l^w(\eta)\) (term \(B\)) |
|
|
\(\delta_{t1}\), \(\delta_{r1}\), \(\delta_{t2}\), \(\delta_{r2}\) |
|
attributes of |
\(\partial w/\partial\xi\), \(\partial w/\partial\eta\) |
|
|
\(w_{,x}\), \(w_{,y}\) |
|
|
\(\boldsymbol{\varepsilon}^{(0)}\), \(\boldsymbol{\varepsilon}^{(1)}\) |
|
|
\(\boldsymbol{C}\), i.e. \(\boldsymbol{A}\), \(\boldsymbol{B}\), \(\boldsymbol{D}\) |
|
argument of the kernels, |
\(\boldsymbol{N}\), \(\boldsymbol{M}\) |
|
|
\(\boldsymbol{N}_{NL}\) |
|
|
\(\boldsymbol{N}^0\) |
|
arguments of |
\(\boldsymbol{N}^0 + \boldsymbol{N}_L\) |
|
|
\(x_2-x_1\), \(y_2-y_1\) |
|
numerical kernels |
\(W_p W_q\) |
|
numerical kernels |
\(n_x\), \(n_y\) |
|
attributes of |
row term \(A = (i,j)\), column term \(B = (k,l)\) |
|
kernels |
\(3(jm+i)\) |
|
|
offsets of \(\boldsymbol{L}_p\) |
|
|
The two last rows of the next table hold the terms whose placement differs between the two namings.
Herein |
Code name |
Function |
|---|---|---|
\(\boldsymbol{K}_{C0}\) |
|
|
\(\boldsymbol{K}_{C0}\) |
|
|
\(\boldsymbol{K}_{CNL}\) |
|
|
\(\boldsymbol{K}_G\) |
|
|
\(\boldsymbol{K}_G(\boldsymbol{N}^0)\) |
|
|
\(\boldsymbol{F}_{int}\) |
|
|
\(\boldsymbol{K}_T\) |
|
|
\(\boldsymbol{K}_{CNL}\), part of |
|
|
not in \(\boldsymbol{K}_T\) |
|
|
Verification#
Three test modules pin the claims of this section. The first two are
parametrised over plate_clpt_donnell and cylshell_clpt_donnell, both
using an unsymmetric, unbalanced laminate so that \(\boldsymbol{B} \neq
\boldsymbol{0}\) and \(A_{16}, A_{26} \neq 0\).
tests/tests_shell/test_tangent_consistency.py verifies the Jacobian
claim of (13) directly:
test_tangent_is_derivative_of_fintis the directional Taylor test. Given an arbitrary state \(\bar{\boldsymbol{c}}\) and direction \(\boldsymbol{d}\), \(\boldsymbol{F}_{int}(\bar{\boldsymbol{c}} + h\boldsymbol{d}) = \boldsymbol{F}_{int}(\bar{\boldsymbol{c}}) + h \boldsymbol{K}_T(\bar{\boldsymbol{c}}) \boldsymbol{d} + O(h^2)\), so the relative error\[e(h) = \frac{\left\| \boldsymbol{F}_{int}(\bar{\boldsymbol{c}} + h\boldsymbol{d}) - \boldsymbol{F}_{int}(\bar{\boldsymbol{c}}) - h \boldsymbol{K}_T \boldsymbol{d} \right\|}{\left\| h \boldsymbol{K}_T \boldsymbol{d} \right\|}\]must fall proportionally to \(h\). The test asserts a factor of at least five per decade over \(h \in [10^{-6}, 10^{-2}]\), and that \(e(h)/h\) does not drift by more than a factor of two. An inconsistent tangent leaves a remainder of order \(h\) and \(e(h)\) plateaus instead.
test_tangent_matches_finite_difference_jacobiancompares every entry of \(\boldsymbol{K}_T\) against a central finite-difference Jacobian of \(\boldsymbol{F}_{int}\).test_kG_is_homogeneous_of_degree_oneasserts \(\boldsymbol{K}_G(2\bar{\boldsymbol{c}}) = 2\boldsymbol{K}_G(\bar{\boldsymbol{c}})\), which is the property that the placement of \(\boldsymbol{K}_{GNL}\) in (19) exists to preserve.test_fint_and_tangent_in_the_undeformed_stateasserts \(\boldsymbol{F}_{int}(\boldsymbol{0}) = \boldsymbol{0}\) and \(\boldsymbol{K}_T(\boldsymbol{0}) = \boldsymbol{K}_{C0}\), which is (20) at \(\bar{\boldsymbol{c}} = \boldsymbol{0}\).test_newton_raphson_converges_quadraticallymanufactures an equilibrium state and checks that the error is squared at each iteration.
tests/tests_shell/test_nonlinear.py exercises the same tangent in a full
Newton-Raphson solution, deep in the postbuckling regime at about 37 times
the first linear buckling load, and asserts both the converged deflection
and a convergence order above \(1.6\).
tests/tests_shell/test_argument_validation.py pins the argument handling
around these matrices, and with it three claims of this section:
test_KGNL_is_in_calc_kC_and_not_in_calc_kGasserts that \(\boldsymbol{K}_G\) is homogeneous of degree one, thatNLgeomdoes not change its value, and that \(\boldsymbol{K}_T\) is exactly the sum of the two matrices of (19).test_NLgeom_and_not_c_selects_the_large_displacement_matrixasserts thatfkC_numreturns \(\boldsymbol{K}_{C0}\) wheneverNLgeom=0, whatever \(\bar{\boldsymbol{c}}\), and that the result then matches the closed-formfk0, as stated when mapping the code onto (20).test_cylshell_approaches_plate_as_radius_growsasserts the monotonic convergence of On presenting the plate as the limit of the cylinder, andtest_cylshell_requires_positive_radiusthat \(r = 0\), a negative \(r\) and an unset \(r\) are all rejected for the cylinder.
Together the first two modules support every claim made here about \(\boldsymbol{K}_T\) being the exact Jacobian, about \(\boldsymbol{K}_G\) being homogeneous of degree one, and about \(\boldsymbol{K}_T(\boldsymbol{0}) = \boldsymbol{K}_{C0}\). They do not separately test the decomposition of (19) term by term, nor the closed-form path against the numerically integrated one; those were verified independently while preparing this section, by rebuilding \(\boldsymbol{B}_L\), \(\boldsymbol{B}_{NL}\) and \(\boldsymbol{G}\) from (7), (9) and (8) at the Gauss points and comparing against the compiled kernels, with the following results, for both models, \(m = n = 4\), \(n_x = n_y = 10\), an unsymmetric laminate and a random \(\bar{\boldsymbol{c}}\) with deflections of twenty thicknesses.
Kernel |
Equation |
Plate |
Cylinder |
|---|---|---|---|
|
\(\int (\boldsymbol{B}_L + \boldsymbol{B}_{NL})^\top \boldsymbol{C} (\boldsymbol{B}_L + \boldsymbol{B}_{NL}) + \boldsymbol{K}_{GNL}\) |
\(4.0 \times 10^{-16}\) |
\(2.3 \times 10^{-16}\) |
|
\(\boldsymbol{K}_G(\boldsymbol{N}^0 + \boldsymbol{N}_L)\), (14) |
\(5.7 \times 10^{-16}\) |
\(4.9 \times 10^{-16}\) |
|
\(\int (\boldsymbol{B}_L + \boldsymbol{B}_{NL})^\top \hat{\boldsymbol{\sigma}}\), (11) |
\(2.2 \times 10^{-16}\) |
\(2.2 \times 10^{-16}\) |
|
|
\(4.7 \times 10^{-15}\) |
\(4.6 \times 10^{-15}\) |
|
|
\(8.6 \times 10^{-15}\) |
\(8.8 \times 10^{-15}\) |
The last two rows also establish that the closed-form and the numerically integrated paths agree to round-off once the quadrature is fine enough to integrate the polynomials exactly, so the choice between them of The two implementation paths is a matter of cost and of capability, not of accuracy.
Where the naming and the code do not line up#
Two points deserve to be stated rather than smoothed over.
The \(\boldsymbol{K}_C\)–\(\boldsymbol{K}_G\) boundary moves. The split of (3) and the split of (2) are not the same split, although both sum to the same \(\boldsymbol{K}_T\). Writing \(\boldsymbol{K}_C\) and \(\boldsymbol{K}_G\) for the classical constitutive and geometric stiffness matrices, defined respectively as the first and the second integral of (13):
The term \(\boldsymbol{K}_{GNL}\) is geometric by construction, being \(\int
\boldsymbol{G}^\top \tilde{\boldsymbol{N}}_{NL} \boldsymbol{G} \,
d\mathcal{A}\), yet under the naming of (3) it belongs to
\(\boldsymbol{K}_{CNL}\). The three-way split therefore partitions the five
terms of the code cleanly, but only if \(\boldsymbol{K}_{CNL}\) is defined
operationally, as everything fkC_num adds beyond
\(\bar{\boldsymbol{c}} = \boldsymbol{0}\), and not semantically, as the
non-linear constitutive terms. Under the semantic reading,
\(\boldsymbol{K}_{GNL}\) has no home in any of the three symbols and the
partition fails. This section adopts the operational reading, and
(20) states it.
Shell.calc_kC() does not always return \(\boldsymbol{K}_{C0} +
\boldsymbol{K}_{CNL}\). If c_cte is given or any of Nxx_cte,
Nyy_cte, Nxy_cte is non-zero, a geometric block is added into the
matrix returned as kC, either \(\boldsymbol{K}_G(\boldsymbol{N}^{cte})\)
through fkG0 or \(\boldsymbol{K}_G\) evaluated at c_cte through
fkG_num. This is the superposition device of combined load cases, which
keeps a part of the stress state out of reach of the load multiplier
\(\lambda\). With NLgeom=True and follower pressure loads, their load
stiffness Shell.calc_kCfollower() is added as well. The symbol
\(\boldsymbol{K}_{C0} + \boldsymbol{K}_{CNL}\) therefore describes the return
value of Shell.calc_kC() only when no constant pre-stress and no
follower load is declared. The two modules of
Verification that verify the Jacobian set the
constant stresses to zero and do not exercise this branch; the third module
does, but only to check that it is reached safely, not the value of the
block.
Note on symbols#
Three groups of symbols are introduced here because the implementation distinguishes quantities that the classical treatment does not:
\(\boldsymbol{K}_{C0}\) and \(\boldsymbol{K}_{CNL}\), for the constant and the state-dependent parts of the matrix returned by
fkC_num. The classical symbol for the first of them is \(\boldsymbol{K}_0\), also used here, in (16); \(\boldsymbol{K}_{C0}\) is kept in (3) because the three-way split it belongs to is the one the implementation is organised around.\(\boldsymbol{N}^0\), \(\boldsymbol{N}_L\) and \(\boldsymbol{N}_{NL}\), for the three contributions to the membrane stress entering (14). The decomposition is needed here because the implementation evaluates the third one separately, and places it on the other side of the \(\boldsymbol{K}_C\)–\(\boldsymbol{K}_G\) boundary.
\(\boldsymbol{L}_p\), for the Boolean selection operator of the multi-domain assembly of (23), which has no counterpart in the single-domain treatment.
Product of the tangent stiffness matrix by a vector#
Shell.calc_kTv() and MultiDomain.calc_kTv() return the
product \([K_T(c)] \{v\}\) of the tangent stiffness matrix of
Shell.calc_kT() by a vector, without assembling \([K_T]\), for all the
geometrically non-linear models. Its cost is that of one evaluation of the
internal force vector, instead of the assembly of \([K_T]\), whose cost grows
with the square of the number of terms of the approximation. It is the
exact product, to round-off, and the exact directional derivative of
Shell.calc_fint().
The product is what the prescribed displacements imposed exactly need at each configuration, \(\partial R_r/\partial \lambda = [T]^T (\{F_{ext}\} - [K_T(c)] \{c_{p1}\})\), see Prescribed displacements imposed exactly, and what matrix-free iterative solvers and directional checks of the tangent need.
Derivation#
All the geometrically non-linear models, the plates and the Donnell and Sanders cylinders of the CLPT, FSDT and TSDT, have at an integration point the generalized strains
where \(\{\beta\}\) are the two rotations of the von Kármán terms, \(w_{,x}\) and \(w_{,y}\) for the Donnell kinematics, \(w_{,y} - v/r\) replacing \(w_{,y}\) for Sanders, and \([B(\beta)]\) is the strain operator, linear in \(\{\beta\}\), which enters only its membrane rows:
Half the rotations in \([B]\) give the quadratic terms \(\beta_x^2/2\), \(\beta_y^2/2\) and \(\beta_x \beta_y\). The internal force vector is \(\{F_{int}\} = \int [B(\beta)]^T [F] \{\varepsilon(c)\} dA\), and its directional derivative along \(\{v\}\) is
because the strains are a quadratic form whose bilinear form is
\([B(\beta)]\), such that \(\frac{d}{dt} \{\varepsilon(c + t v)\} =
[B(\beta)] \{v\}\), and \(\frac{d}{dt} [B]^T \{\sigma\} = [G]^T [N] [G]
\{v\}\) from the derivatives of \([B]\) above, with \([N]\) the membrane
stresses. The two terms are those assembled by fkC_num and fkG_num
of each model. The constant membrane stresses Nxx_cte, Nyy_cte and
Nxy_cte, whose geometric stiffness Shell.calc_kC() adds to the
tangent stiffness matrix, are added to \([N]\).
At each integration point, with the approximation functions tabulated once,
the kernels calc_kTv of panels/models/<model>_num.pyx compute
\(\{\beta\} = [G] \{c\}\) and \([G] \{v\}\);
\(\{\varepsilon\} = [B(\beta/2)] \{c\}\) and \(\{\delta \varepsilon\} = [B(\beta)] \{v\}\);
the stresses \([F] \{\varepsilon\}\), of which \([N]\), and \([F] \{\delta \varepsilon\}\);
the sum of (25) over the terms.
Each step is a loop over the \(m n\) terms, against a loop over the \(m^2 n^2\) pairs of terms for the assembly of \([K_T]\).
The expressions are generated by theory/shells/tangent_product, which
also verifies with SymPy, for every model, that (25) is the derivative
of the internal force vector of one term, with a symbolic constitutive
matrix; \([B]\) and \([G]\) being linear in the degrees of freedom, the
identity extends to any number of terms.
Follower pressure loads#
With follower pressure loads, Shell.calc_kTv() adds the product of
their load stiffness Shell.calc_kCfollower() as the central
difference
which is exact for any \(t\), the force vector \(\{F_p(c)\}\) being affine in
\(\{c\}\) for follower='linear' and quadratic for
follower='quadratic', see Follower pressure loads: theory. \(t = \max(\|c\|/\|v\|,
1)\) avoids the cancellation of large and small terms.
Verification#
The checks of the tangent stiffness matrix of
tests/tests_shell/test_tangent_consistency.py are repeated for the
product, for all 9 models, in tests/tests_shell/test_tangent_product.py:
the product equals
calc_kT(c) @ vwith a relative difference below \(10^{-12}\), also with a constant membrane stress state, one constitutive matrix per integration point, a partial integration domain, an offset in a larger vector, and dead and follower pressure loads at a load factorinc;the directional Taylor test of
Shell.calc_fint(), whose remainder falls as \(O(h^2)\);the matrix built column by column from the product against the central difference of
Shell.calc_fint();in the undeformed state the product is \([K_0] \{v\}\);
Newton-Raphson with the tangent built from the product converges quadratically:
@pytest.mark.parametrize('model', MODELS)
def test_newton_raphson_converges_quadratically(model):
"""Newton-Raphson around a known equilibrium, with the tangent built from
the product, must square the error; see the test of the same name in
``test_tangent_consistency.py``"""
s, fint, kTv, KT = make_callables(model)
def KT_from_product(c):
N = c.shape[0]
K = np.empty((N, N))
for j in range(N):
e = np.zeros(N)
e[j] = 1.
K[:, j] = kTv(c, e)
return K
for seed in range(13, 33):
rng = np.random.default_rng(seed)
c_eq = random_state(s, rng)
used = ~np.isclose(KT(c_eq).diagonal(), 0)
c_eq[~used] = 0
K0 = s.calc_kC(c=np.zeros_like(c_eq)).toarray()[np.ix_(used, used)]
lam = eigh(KT(c_eq)[np.ix_(used, used)], K0, eigvals_only=True)
if np.abs(lam).min() > 0.05:
break
else:
raise AssertionError('no regular equilibrium state found')
fext = fint(c_eq)
c = c_eq + 1.e-2*random_state(s, rng)
c[~used] = 0
errors = [np.linalg.norm(c - c_eq)/np.linalg.norm(c_eq)]
while errors[-1] > 1.e-8 and len(errors) < 12:
K = KT_from_product(c)[np.ix_(used, used)]
R = fint(c) - fext
c[used] -= np.linalg.solve(K, R[used])
errors.append(np.linalg.norm(c - c_eq)/np.linalg.norm(c_eq))
assert len(errors) - 1 <= 6, 'too many iterations: %s' % errors
floor = 1.e-9 if modelDB.db[model]['dofs'] == 5 else 1.e-12
orders = [np.log(e2/e1)/np.log(e1/e0)
for e0, e1, e2 in zip(errors[:-2], errors[1:-1], errors[2:])
if e2 > floor]
assert len(orders) >= 1, 'no step above the round-off floor: %s' % errors
assert min(orders) > 1.5, 'convergence is not quadratic: %s' % errors
tests/multidomain/test_tangent_product_multidomain.py checks
MultiDomain.calc_kTv() with penalty connections and follower loads,
and \(\partial R_r/\partial \lambda\) of the prescribed displacements against
the assembled tangent stiffness matrix.
Performance#
Single shell, laminate [0, 45, -45, 90], nx = 2m, ny = 2n
integration points, one core; Shell.calc_kT() with the tabulated
kernels, see Approximation functions tabulated at the integration points:
model |
\(m = n\) |
size |
|
|
|
ratio |
difference |
|---|---|---|---|---|---|---|---|
|
8 / 12 / 16 |
192 / 432 / 768 |
0.035 / 0.35 / 1.9 s |
1.5 / 8.1 / 28 ms |
1.8 / 9.6 / 32 ms |
20 / 37 / 59 |
\(2 \times 10^{-15}\) |
|
8 / 12 / 16 |
320 / 720 / 1280 |
0.16 / 1.7 / 9.1 s |
2.6 / 14 / 47 ms |
2.9 / 15 / 51 ms |
54 / 109 / 180 |
\(2 \times 10^{-15}\) |
|
8 / 12 / 16 |
320 / 720 / 1280 |
0.28 / 3.0 / 17 s |
2.8 / 15 / 50 ms |
3.1 / 16 / 55 ms |
91 / 184 / 304 |
\(2 \times 10^{-15}\) |
The product costs about one Shell.calc_fint(), and the ratio grows
with the number of terms. With the previous kernels, whose approximation
functions were evaluated in the innermost loops, calc_kT took 7.0, 10.9
and 11.9 s for \(m = n = 12\), i.e. 700 times the product.
Displacement-controlled post-buckling of the plate of
tests/multidomain/test_null_space_pd.py with \(m = n = 12\) (407 reduced
degrees of freedom), prescribed displacements imposed exactly, the four
combinations of panels before (0.12.0, with the previous
kernels and the assembly of \([K_T]\) for \(\partial R_r/\partial \lambda\))
and after these developments with structsolve 0.7.0 and 0.8.0. The
reactions at \(\lambda = 1\) agree to all the digits printed:
|
|
Newton-Raphson |
modified Newton-Raphson |
Riks |
|---|---|---|---|---|
before |
0.7.0 |
285 s |
705 s |
363 s |
before |
0.8.0 |
229 s |
158 s |
350 s |
after |
0.7.0 |
11.9 s |
9.1 s |
18.7 s |
after |
0.8.0 |
11.8 s |
7.8 s |
18.4 s |
Before, almost all the time was in the assembly of \([K_T]\) inside
calc_fext, for \(\partial R_r/\partial \lambda\); after, it is in the
assembly of \([K_T]\) for the Newton iterations, 32, 20 and 50 assemblies,
the products costing 0.2 to 0.5 s in total.