ANCFThinPlateRevisionTest.py

You can view and download this file on Github: ANCFThinPlateRevisionTest.py

  1#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
  2# This is an EXUDYN example
  3#
  4# Details:  Tests of ObjectANCFThinPlate and exudyn.shells (#2857, #2858): the integration modes against
  5#           F L^3/(3 EI), the material curvature measure, large-rotation pure bending, the first
  6#           eigenfrequency, the mass, gravity, variable thickness (4 and 12 values), Kelvin-Voigt
  7#           damping (no damping of a rigid motion, the rule of the bending damping), the output
  8#           variables, ShellMesh with a surface map and a thickness function, and the visualization
  9#           settings of shells. Small meshes and linear or few-step static solutions; damping is checked
 10#           with the linearized system. Each test prints the measured and the reference value.
 11#           The result is the number of tests that passed.
 12#
 13# Author:   Michael Pieber, Johannes Gerstmayr
 14# Date:     2026-10-01, ported 2026-10-05
 15#
 16# Copyright:This file is part of Exudyn. Exudyn is free software. You can redistribute it and/or modify it under the terms of the Exudyn license. See 'LICENSE.txt' for more details.
 17#
 18#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 19
 20import io
 21import unittest
 22import numpy as np
 23import exudyn as exu
 24from exudyn.utilities import (MarkerNodePosition, LoadForceVector, MarkerNodeCoordinate, NodePointGround,
 25                              CoordinateConstraint)
 26from exudyn.shells import ShellMesh, SurfaceMap, AddNodeConstraints
 27from exudyn.utilities import MarkerBodyMass, LoadMassProportional
 28
 29#material and strip geometry (Poisson ratio 0: plate strip = Euler-Bernoulli beam)
 30youngsModulus = 2.1e11
 31poissonRatio = 0.
 32density = 7800.
 33stripLength = 1.0
 34stripWidth = 0.1
 35stripThickness = 0.01
 36bendingStiffness = youngsModulus * stripWidth * stripThickness**3 / 12.     #EI of the strip
 37plateBendingStiffness = youngsModulus * stripThickness**3 / 12.               #D per unit width (nu = 0)
 38tipForce = 1.
 39gravity = 9.81
 40numberOfPlateDOFsPerNode = 9                   #position, slope x, slope y
 41firstBeamEigenvalueFactor = 1.87510407         #lambda_1 of the clamped-free Euler-Bernoulli beam
 42numberOfTipLoadSteps = 1
 43numberOfBendingLoadSteps = 5
 44
 45
 46def StaticSettings(numberOfLoadSteps=numberOfTipLoadSteps):
 47    """Return simulation settings for the static tests (no solution file, sparse solver).
 48    @param numberOfLoadSteps: number of static load steps
 49    @return: exu.SimulationSettings"""
 50    simulationSettings = exu.SimulationSettings()
 51    simulationSettings.staticSolver.numberOfLoadSteps = numberOfLoadSteps
 52    simulationSettings.staticSolver.newton.relativeTolerance = 1e-10
 53    simulationSettings.staticSolver.newton.absoluteTolerance = 1e-3   #round-off floor of the residual (N)
 54    simulationSettings.staticSolver.verboseMode = 0
 55    simulationSettings.solution.file.write = False
 56    simulationSettings.linearSolver.solverType = exu.LinearSolverType.EigenSparse
 57    return simulationSettings
 58
 59
 60def CreateStrip(numberOfElements=4, integrationMode=0, alongY=False, clamp=True, thicknessField=None,
 61                massProportionalLoad=None, stiffnessProportionalDamping=0.):
 62    """Create a plate strip (stripLength x stripWidth, one element across the width) with ShellMesh, optionally
 63    clamped at the edge at the origin.
 64    @param numberOfElements: number of elements along the strip
 65    @param integrationMode: useReducedOrderIntegration of the elements
 66    @param alongY: True: strip axis along global y, False: along global x
 67    @param clamp: True: all 9 coordinates of the nodes at the origin edge are fixed
 68    @param thicknessField: function f(x, y) of the thickness, evaluated at the nodes (4 values per element)
 69    @param massProportionalLoad: a load per mass, e.g. gravity, on every element
 70    @param stiffnessProportionalDamping: Kelvin-Voigt damping coefficient of ShellMesh
 71    @return: (system container, mbs, dictionary of nodes, elements, edgeNodeNumbers and nodeId, list of tip node numbers)"""
 72    systemContainer = exu.SystemContainer()
 73    mbs = systemContainer.AddSystem()
 74    (lengthX, lengthY, nx, ny) = ((stripWidth, stripLength, 1, numberOfElements) if alongY
 75                                  else (stripLength, stripWidth, numberOfElements, 1))
 76    plate = ShellMesh(vertices=[[0, 0, 0], [lengthX, 0, 0], [lengthX, lengthY, 0], [0, lengthY, 0]],
 77                      numberOfElementsX=nx, numberOfElementsY=ny, youngsModulus=youngsModulus,
 78                      poissonsRatio=poissonRatio, density=density, thickness=stripThickness,
 79                      stiffnessProportionalDamping=stiffnessProportionalDamping)
 80    if thicknessField is not None:
 81        plate.thicknessAtNodes = [thicknessField(ix * lengthX / nx, iy * lengthY / ny)
 82                                  for iy in range(ny + 1) for ix in range(nx + 1)]
 83    plate.CreateANCFThinPlateElements(mbs, useReducedOrderIntegration=integrationMode)
 84    if massProportionalLoad is not None:
 85        for elementNumber in plate.elementNumbers:
 86            mbs.AddLoad(LoadMassProportional(markerNumber=mbs.AddMarker(MarkerBodyMass(bodyNumber=elementNumber)),
 87                                             loadVector=massProportionalLoad))
 88    built = {'nodes': plate.nodeNumbers, 'elements': plate.elementNumbers,
 89             'edgeNodeNumbers': plate.boundaryNodeNumbers, 'nodeId': lambda iy, ix: iy * (nx + 1) + ix}
 90    if alongY:
 91        clampNodes, tipNodes = built['edgeNodeNumbers']['bottom'], built['edgeNodeNumbers']['top']
 92    else:
 93        clampNodes, tipNodes = built['edgeNodeNumbers']['left'], built['edgeNodeNumbers']['right']
 94    if clamp:
 95        for nodeNumber in clampNodes:
 96            AddNodeConstraints(mbs, nodeNumber, dofs=list(range(numberOfPlateDOFsPerNode)))
 97    return systemContainer, mbs, built, tipNodes
 98
 99
100def AddTipLoad(mbs, tipNodes, force=tipForce):
101    """Distribute a force in -z equally on the tip nodes.
102    @param mbs: main system
103    @param tipNodes: list of tip node numbers
104    @param force: total force (N)"""
105    for nodeNumber in tipNodes:
106        markerNumber = mbs.AddMarker(MarkerNodePosition(nodeNumber=nodeNumber))
107        mbs.AddLoad(LoadForceVector(markerNumber=markerNumber, loadVector=[0, 0, -force / len(tipNodes)]))
108
109
110def TipDeflection(mbs, tipNodes):
111    """Return the mean downward (-z) displacement of the tip nodes.
112    @param mbs: main system (solved)
113    @param tipNodes: list of tip node numbers
114    @return: deflection (m), positive downward"""
115    return -np.mean([mbs.GetNodeOutput(nodeNumber, exu.OutputVariableType.Displacement)[2] for nodeNumber in tipNodes])
116
117
118def RigidMotionVectors(mbs, rotationMatrix, angularVelocity):
119    """Coordinates of a rigid rotation of all plate nodes and the velocities of a rigid angular velocity in
120    this rotated configuration (positions and both slope vectors rotate with the body).
121    @param mbs: main system (assembled, NodePointSlope12 nodes only)
122    @param rotationMatrix: 3x3 rotation of the reference configuration
123    @param angularVelocity: angular velocity vector (rad/s)
124    @return: (ODE2 coordinates, ODE2 velocities) as numpy arrays"""
125    coordinates = np.zeros(mbs.systemData.ODE2Size())
126    velocities = np.zeros(mbs.systemData.ODE2Size())
127    for nodeNumber in range(mbs.systemData.NumberOfNodes()):
128        referenceCoordinates = np.array(mbs.GetNodeParameter(nodeNumber, 'referenceCoordinates'))
129        firstIndex = mbs.GetNodeODE2Index(nodeNumber)
130        for vectorIndex in range(3):
131            referenceVector = referenceCoordinates[3 * vectorIndex:3 * vectorIndex + 3]
132            rotatedVector = rotationMatrix @ referenceVector
133            coordinates[firstIndex + 3 * vectorIndex:firstIndex + 3 * vectorIndex + 3] = rotatedVector - referenceVector
134            velocities[firstIndex + 3 * vectorIndex:firstIndex + 3 * vectorIndex + 3] = np.cross(angularVelocity, rotatedVector)
135    return coordinates, velocities
136
137
138def PrintComparison(label, measured, reference):
139    """Verbose test output: measured value, reference value and relative deviation.
140    @param label: description of the quantity
141    @param measured: measured value
142    @param reference: reference value (nonzero)"""
143    print(f'\n    {label}: measured {measured:.9e}, reference {reference:.9e}, '
144          f'relative deviation {measured / reference - 1:+.3e}', end='')
145
146
147class TestANCFThinPlateElement(unittest.TestCase):
148    """Kinematics, integration, mass, gravity and thickness of ObjectANCFThinPlate."""
149
150    def testCantileverTipDeflectionAllIntegrationModes(self):
151        """Linear cantilever with tip load: cubic Hermite elements are exact, F L^3/(3 EI), for modes 0, 1, 2;
152        mode 2 is an alias of mode 1 (bitwise identical)."""
153        deflections = {}
154        for integrationMode in [0, 1, 2]:
155            systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=2, integrationMode=integrationMode)
156            AddTipLoad(mbs, tipNodes)
157            mbs.Assemble()
158            mbs.SolveStatic(StaticSettings())
159            deflections[integrationMode] = TipDeflection(mbs, tipNodes)
160            reference = tipForce * stripLength**3 / (3 * bendingStiffness)
161            PrintComparison(f'tip deflection mode {integrationMode}', deflections[integrationMode], reference)
162            self.assertAlmostEqual(deflections[integrationMode] / reference, 1., delta=1e-6)
163        self.assertEqual(deflections[1], deflections[2])
164
165    @unittest.skipIf('[FAST]' in exu.config.Version(True),
166                     'exudynCPPfast compiles the error checks away by design (#2911)')
167    def testInvalidIntegrationModeRaises(self):
168        """useReducedOrderIntegration = 3 must stop the solver with an error."""
169        systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=2, integrationMode=3)
170        mbs.Assemble()
171        printToConsole = exu.config.printToConsole
172        exu.config.printToConsole = False    #the expected error message would clutter the test output
173        try:
174            with self.assertRaises(ValueError):
175                mbs.SolveStatic(StaticSettings())
176        finally:
177            exu.config.printToConsole = printToConsole
178
179    def testMaterialCurvatureOfStretchedArc(self):
180        """Material curvature measure: a strip placed exactly on a circular arc of radius rho and stretched by
181        lambda has |kappa_xx| = lambda/rho (geometric measure: 1/rho) and Green-Lagrange strain (lambda^2-1)/2.
182        Pure kinematics, no solver."""
183        stretch, radius = 1.2, 1.0
184        systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=4, clamp=False)
185        mbs.Assemble()
186        coordinates = np.zeros(mbs.systemData.ODE2Size())
187        for nodeNumber in range(mbs.systemData.NumberOfNodes()):
188            referenceCoordinates = np.array(mbs.GetNodeParameter(nodeNumber, 'referenceCoordinates'))
189            arcAngle = stretch * referenceCoordinates[0] / radius
190            position = np.array([radius * np.sin(arcAngle), referenceCoordinates[1], radius * (1 - np.cos(arcAngle))])
191            slopeX = stretch * np.array([np.cos(arcAngle), 0, np.sin(arcAngle)])
192            firstIndex = mbs.GetNodeODE2Index(nodeNumber)
193            coordinates[firstIndex:firstIndex + 3] = position - referenceCoordinates[0:3]
194            coordinates[firstIndex + 3:firstIndex + 6] = slopeX - referenceCoordinates[3:6]
195        mbs.systemData.SetODE2Coordinates(coordinates.tolist(), configuration=exu.ConfigurationType.Current)
196        for elementNumber in built['elements']:
197            curvature = mbs.GetObjectOutputBody(elementNumber, exu.OutputVariableType.CurvatureLocal,
198                                                localPosition=[0, 0, 0], configuration=exu.ConfigurationType.Current)
199            strain = mbs.GetObjectOutputBody(elementNumber, exu.OutputVariableType.StrainLocal,
200                                             localPosition=[0, 0, 0], configuration=exu.ConfigurationType.Current)
201            PrintComparison(f'element {elementNumber} |kappa_xx|', abs(curvature[0]), stretch / radius)
202            self.assertAlmostEqual(abs(curvature[0]) / (stretch / radius), 1., delta=1e-2)   #interpolation error 0.4 %
203            self.assertAlmostEqual(strain[0], 0.5 * (stretch**2 - 1), delta=1e-4)
204            self.assertAlmostEqual(curvature[1], 0., delta=1e-10)
205
206    def testPureBendingLargeRotation(self):
207        """Tip slope rotated by 1 rad: the strip forms a circular arc of radius L/theta; curvature theta/L,
208        membrane strain small compared with the surface bending strain."""
209        rotationAngle = 1.0
210        systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=4)
211        groundNode = mbs.AddNode(NodePointGround())
212        targetSlope = [np.cos(rotationAngle), 0., np.sin(rotationAngle)]
213        for nodeNumber in tipNodes:
214            referenceCoordinates = mbs.GetNodeParameter(nodeNumber, 'referenceCoordinates')
215            for component in range(3):
216                groundMarker = mbs.AddMarker(MarkerNodeCoordinate(nodeNumber=groundNode, coordinate=0))
217                slopeMarker = mbs.AddMarker(MarkerNodeCoordinate(nodeNumber=nodeNumber, coordinate=3 + component))
218                mbs.AddObject(CoordinateConstraint(markerNumbers=[groundMarker, slopeMarker],
219                                                   offset=targetSlope[component] - referenceCoordinates[3 + component]))
220        mbs.Assemble()
221        mbs.SolveStatic(StaticSettings(numberOfBendingLoadSteps))
222        tipPosition = np.mean([mbs.GetNodeOutput(nodeNumber, exu.OutputVariableType.Position) for nodeNumber in tipNodes], axis=0)
223        exactTipPosition = stripLength / rotationAngle * np.array([np.sin(rotationAngle), 1 - np.cos(rotationAngle)])
224        arcError = np.linalg.norm(tipPosition[[0, 2]] - exactTipPosition)
225        print(f'\n    tip position error {arcError:.2e} m', end='')
226        self.assertLess(arcError, 1e-5)
227        for elementNumber in built['elements']:
228            curvature = mbs.GetObjectOutputBody(elementNumber, exu.OutputVariableType.CurvatureLocal, localPosition=[0, 0, 0])
229            membraneStrain = mbs.GetObjectOutputBody(elementNumber, exu.OutputVariableType.StrainLocal, localPosition=[0, 0, 0])
230            surfaceStrain = mbs.GetObjectOutputBody(elementNumber, exu.OutputVariableType.StrainLocal, localPosition=[0, 0, 1])
231            PrintComparison(f'element {elementNumber} |kappa_xx|', abs(curvature[0]), rotationAngle / stripLength)
232            self.assertAlmostEqual(abs(curvature[0]) / (rotationAngle / stripLength), 1., delta=5e-3)
233            self.assertLess(abs(membraneStrain[0] / surfaceStrain[0]), 2e-3)
234
235    def testFirstEigenfrequencyCantilever(self):
236        """First bending eigenfrequency of the clamped strip vs. Euler-Bernoulli (mass and stiffness)."""
237        systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=4, clamp=False)
238        mbs.Assemble()
239        constrainedCoordinates = []
240        for nodeNumber in built['edgeNodeNumbers']['left']:
241            firstIndex = mbs.GetNodeODE2Index(nodeNumber)
242            constrainedCoordinates += list(range(firstIndex, firstIndex + numberOfPlateDOFsPerNode))
243        frequencies = mbs.ComputeODE2Eigenvalues(constrainedCoordinates=constrainedCoordinates,
244                                                 convert2Frequencies=True, useSparseSolver=False)[0]
245        reference = (firstBeamEigenvalueFactor**2 / (2 * np.pi)
246                     * np.sqrt(bendingStiffness / (density * stripWidth * stripThickness * stripLength**4)))
247        PrintComparison('first eigenfrequency (Hz)', frequencies[0], reference)
248        self.assertAlmostEqual(frequencies[0] / reference, 1., delta=1e-4)
249
250    def testMassOfLinearlyTaperedPlate(self):
251        """Mass matrix with thickness h(x) = h0 (1 - x/(2L)): total mass rho b h0 L 3/4 (exact)."""
252        systemContainer, mbs, built, tipNodes = CreateStrip(
253            numberOfElements=4, clamp=False, thicknessField=lambda x, y: stripThickness * (1 - 0.5 * x / stripLength))
254        mbs.Assemble()
255        massMatrix = mbs.ComputeLinearizedSystem(StaticSettings())[0]
256        translationZ = np.zeros(mbs.systemData.ODE2Size())
257        for nodeNumber in range(mbs.systemData.NumberOfNodes()):
258            translationZ[mbs.GetNodeODE2Index(nodeNumber) + 2] = 1.
259        reference = density * stripWidth * stripThickness * stripLength * 0.75
260        PrintComparison('total mass (kg)', translationZ @ massMatrix @ translationZ, reference)
261        self.assertAlmostEqual(translationZ @ massMatrix @ translationZ / reference, 1., delta=1e-10)
262
263    def testGravityTipDeflection(self):
264        """Self-weight (LoadMassProportional on each element): tip deflection q L^4/(8 EI)."""
265        systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=4, massProportionalLoad=[0, 0, -gravity])
266        mbs.Assemble()
267        mbs.SolveStatic(StaticSettings())
268        lineLoad = density * stripWidth * stripThickness * gravity
269        reference = lineLoad * stripLength**4 / (8 * bendingStiffness)
270        PrintComparison('tip deflection under self-weight', TipDeflection(mbs, tipNodes), reference)
271        self.assertAlmostEqual(TipDeflection(mbs, tipNodes) / reference, 1., delta=1e-4)
272
273    def testUniformVariableThicknessEqualsConstant(self):
274        """4 equal nodal thicknesses (point-wise stiffness D_base h) give the same result as constant thickness."""
275        deflections = []
276        for builderOptions in [{}, {'thicknessField': lambda x, y: stripThickness}]:
277            systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=2, **builderOptions)
278            AddTipLoad(mbs, tipNodes)
279            mbs.Assemble()
280            mbs.SolveStatic(StaticSettings())
281            deflections.append(TipDeflection(mbs, tipNodes))
282        PrintComparison('tip deflection, 4 equal thickness values', deflections[1], deflections[0])
283        self.assertAlmostEqual(deflections[1] / deflections[0], 1., delta=1e-10)
284
285    def testLinearTaperTipDeflection(self):
286        """Thickness h(x) = h0 (1 - x/(2L)), bending stiffness point-wise with h^3: tip deflection
287        integral of F (L-x)^2 / (E b h(x)^3/12) dx."""
288        thicknessFunction = lambda x, y: stripThickness * (1 - 0.5 * x / stripLength)
289        systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=4, thicknessField=thicknessFunction)
290        AddTipLoad(mbs, tipNodes)
291        mbs.Assemble()
292        mbs.SolveStatic(StaticSettings())
293        positions = np.linspace(0, stripLength, 20001)
294        integrand = tipForce * (stripLength - positions)**2 / (youngsModulus * stripWidth * thicknessFunction(positions, 0)**3 / 12)
295        reference = np.sum(0.5 * (integrand[1:] + integrand[:-1]) * np.diff(positions))   #trapezoidal rule
296        PrintComparison('tip deflection, linear taper', TipDeflection(mbs, tipNodes), reference)
297        self.assertAlmostEqual(TipDeflection(mbs, tipNodes) / reference, 1., delta=1e-3)
298
299    def testTwelveThicknessValuesEqualConstant(self):
300        """12 thickness values [h_i, h_s,i, h_t,i] with zero gradients give the deflection of the constant thickness."""
301        deflections = []
302        for twelveValues in [False, True]:
303            systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=2)
304            if twelveValues:
305                for elementNumber in built['elements']:
306                    mbs.SetObjectParameter(elementNumber, 'thickness', [stripThickness, 0., 0.] * 4)
307            AddTipLoad(mbs, tipNodes)
308            mbs.Assemble()
309            mbs.SolveStatic(StaticSettings())
310            deflections.append(TipDeflection(mbs, tipNodes))
311        PrintComparison('tip deflection, 12 thickness values', deflections[1], deflections[0])
312        self.assertAlmostEqual(deflections[1] / deflections[0], 1., delta=1e-12)
313
314class TestANCFThinPlateDamping(unittest.TestCase):
315    """Kelvin-Voigt damping, checked with the damping matrix of the linearized system."""
316
317    def DampingMatrix(self, membraneDamping, bendingDamping, coordinates=None):
318        """Damping matrix of a free 2-element strip.
319        @param membraneDamping: stiffnessProportionalDamping (s)
320        @param bendingDamping: bendingStiffnessProportionalDamping (s), negative: use membraneDamping
321        @param coordinates: optional ODE2 coordinates (initial and current configuration)
322        @return: (mbs, stiffness matrix, damping matrix)"""
323        systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=2, clamp=False,
324                                                            stiffnessProportionalDamping=membraneDamping)
325        for elementNumber in built['elements']:
326            mbs.SetObjectParameter(elementNumber, 'bendingStiffnessProportionalDamping', bendingDamping)
327        mbs.Assemble()
328        if coordinates is not None:
329            for configuration in [exu.ConfigurationType.Initial, exu.ConfigurationType.Current]:
330                mbs.systemData.SetODE2Coordinates(coordinates.tolist(), configuration=configuration)
331        massMatrix, stiffnessMatrix, dampingMatrix = mbs.ComputeLinearizedSystem(StaticSettings())
332        return mbs, stiffnessMatrix, dampingMatrix
333
334    def testNoDampingOfRigidRotation(self):
335        """In a finitely rotated configuration, a rigid angular velocity produces no damping force (strain
336        rates vanish), while a bending velocity does; also no elastic force change for rigid rotation."""
337        rotationAngle = 1.0
338        rotationMatrix = np.array([[np.cos(rotationAngle), 0, np.sin(rotationAngle)], [0, 1, 0],
339                                   [-np.sin(rotationAngle), 0, np.cos(rotationAngle)]])
340        mbs, stiffnessMatrix, dampingMatrix = self.DampingMatrix(1e-3, -1.)
341        coordinates, rigidVelocities = RigidMotionVectors(mbs, rotationMatrix, np.array([0.3, -0.5, 0.7]))
342        mbs, stiffnessMatrix, dampingMatrix = self.DampingMatrix(1e-3, -1., coordinates)
343        bendingVelocities = np.zeros(mbs.systemData.ODE2Size())
344        for nodeNumber in range(mbs.systemData.NumberOfNodes()):
345            referenceCoordinates = np.array(mbs.GetNodeParameter(nodeNumber, 'referenceCoordinates'))
346            firstIndex = mbs.GetNodeODE2Index(nodeNumber)
347            bendingVelocities[firstIndex:firstIndex + 3] = rotationMatrix @ np.array([0, 0, referenceCoordinates[0]**2])
348        rigidForce = np.linalg.norm(dampingMatrix @ rigidVelocities)
349        bendingForce = np.linalg.norm(dampingMatrix @ bendingVelocities)
350        print(f'\n    |D v_rigid| = {rigidForce:.3e}, |D v_bending| = {bendingForce:.3e}, '
351              f'|K v_rigid|/|K v_bending| = {np.linalg.norm(stiffnessMatrix @ rigidVelocities) / np.linalg.norm(stiffnessMatrix @ bendingVelocities):.3e}', end='')
352        self.assertGreater(bendingForce, 1.)
353        self.assertLess(rigidForce / bendingForce, 1e-9)
354        self.assertLess(np.linalg.norm(stiffnessMatrix @ rigidVelocities) / np.linalg.norm(stiffnessMatrix @ bendingVelocities), 1e-9)
355
356    def testBendingDampingParameterRule(self):
357        """Flat strip: out-of-plane velocity w = x^2 only strains in bending, in-plane velocity u = x only in the
358        membrane. beta_kappa >= 0 is used as is (0 switches bending damping off), negative uses beta_epsilon."""
359        cases = [(1e-3, 0., False, True), (1e-3, -1., True, True), (0., 1e-3, True, False), (0., -1., False, False)]
360        for membraneDamping, bendingDamping, bendingDamped, membraneDamped in cases:
361            mbs, stiffnessMatrix, dampingMatrix = self.DampingMatrix(membraneDamping, bendingDamping)
362            bendingVelocities = np.zeros(mbs.systemData.ODE2Size())
363            stretchVelocities = np.zeros(mbs.systemData.ODE2Size())
364            for nodeNumber in range(mbs.systemData.NumberOfNodes()):
365                referenceCoordinates = np.array(mbs.GetNodeParameter(nodeNumber, 'referenceCoordinates'))
366                firstIndex = mbs.GetNodeODE2Index(nodeNumber)
367                bendingVelocities[firstIndex + 2] = referenceCoordinates[0]**2          #w = x^2
368                bendingVelocities[firstIndex + 5] = 2 * referenceCoordinates[0]         #w_x = 2x
369                stretchVelocities[firstIndex] = referenceCoordinates[0]                 #u = x
370                stretchVelocities[firstIndex + 3] = 1.                                  #u_x = 1
371            bendingForce = np.linalg.norm(dampingMatrix @ bendingVelocities)
372            stretchForce = np.linalg.norm(dampingMatrix @ stretchVelocities)
373            print(f'\n    beta_epsilon = {membraneDamping}, beta_kappa = {bendingDamping}: '
374                  f'|D v_bending| = {bendingForce:.3e}, |D v_stretch| = {stretchForce:.3e}', end='')
375            self.assertEqual(bendingForce > 1e-6, bendingDamped)
376            self.assertEqual(stretchForce > 1e-6, membraneDamped)
377
378
379class TestANCFThinPlateOutputs(unittest.TestCase):
380    """Output variables StressLocal, StrainLocal, CurvatureLocal, ForceLocal, TorqueLocal."""
381
382    def SolvedTipLoadStrip(self, alongY):
383        """Clamped 4-element strip with tip load in -z (concave down), solved.
384        @param alongY: strip axis along global y
385        @return: (mbs, built dictionary)"""
386        systemContainer, mbs, built, tipNodes = CreateStrip(numberOfElements=4, alongY=alongY)
387        AddTipLoad(mbs, tipNodes)
388        mbs.Assemble()
389        mbs.SolveStatic(StaticSettings())
390        return mbs, built
391
392    def testOutputVectorSizes(self):
393        """StressLocal and StrainLocal have 6 components, CurvatureLocal, ForceLocal and TorqueLocal 3."""
394        mbs, built = self.SolvedTipLoadStrip(False)
395        expectedSizes = {exu.OutputVariableType.StressLocal: 6, exu.OutputVariableType.StrainLocal: 6,
396                         exu.OutputVariableType.CurvatureLocal: 3, exu.OutputVariableType.ForceLocal: 3,
397                         exu.OutputVariableType.TorqueLocal: 3}
398        for outputVariableType, expectedSize in expectedSizes.items():
399            value = mbs.GetObjectOutputBody(built['elements'][0], outputVariableType, localPosition=[0, 0, 0])
400            print(f'\n    {outputVariableType}: {len(value)} components', end='')
401            self.assertEqual(len(value), expectedSize)
402
403    def testStressStrainAndBendingMoment(self):
404        """Top fiber (zeta = +1) near the clamp in tension for concave-down bending along x and along y;
405        sigma = E epsilon (nu = 0); |sigma| = 6 m/h^2 with m = F (L - s)/b at the element center."""
406        for alongY in [False, True]:
407            mbs, built = self.SolvedTipLoadStrip(alongY)
408            component = 1 if alongY else 0
409            elementNumber = built['elements'][0]
410            stressTop = mbs.GetObjectOutputBody(elementNumber, exu.OutputVariableType.StressLocal, localPosition=[0, 0, 1])[component]
411            strainTop = mbs.GetObjectOutputBody(elementNumber, exu.OutputVariableType.StrainLocal, localPosition=[0, 0, 1])[component]
412            momentPerWidth = tipForce * (stripLength - 0.5 * stripLength / 4) / stripWidth
413            PrintComparison(f'strip along {"y" if alongY else "x"}: top stress', stressTop, 6 * momentPerWidth / stripThickness**2)
414            self.assertGreater(stressTop, 0.)
415            self.assertAlmostEqual(stressTop / (youngsModulus * strainTop), 1., delta=1e-8)
416            self.assertAlmostEqual(stressTop / (6 * momentPerWidth / stripThickness**2), 1., delta=1e-5)   #nonlinear static solution
417
418    def testMomentEqualsPlateStiffnessTimesCurvature(self):
419        """TorqueLocal = E h^3/12 CurvatureLocal component-wise (nu = 0), i.e. moment and curvature use the same
420        sign convention, for strips along x and along y."""
421        for alongY in [False, True]:
422            mbs, built = self.SolvedTipLoadStrip(alongY)
423            component = 1 if alongY else 0
424            curvature = mbs.GetObjectOutputBody(built['elements'][0], exu.OutputVariableType.CurvatureLocal, localPosition=[0, 0, 0])
425            moment = mbs.GetObjectOutputBody(built['elements'][0], exu.OutputVariableType.TorqueLocal, localPosition=[0, 0, 0])
426            PrintComparison(f'strip along {"y" if alongY else "x"}: moment/curvature', moment[component] / curvature[component],
427                            plateBendingStiffness)
428            self.assertAlmostEqual(moment[component] / curvature[component] / plateBendingStiffness, 1., delta=1e-8)
429
430    def testCurvatureSignSameInBothDirections(self):
431        """For the same concave-down bending, CurvatureLocal component 0 (strip along x) and component 1 (strip
432        along y) have the same sign, the sign of ObjectANCFCable2D: negative (#2864)."""
433        curvatures = []
434        for alongY in [False, True]:
435            mbs, built = self.SolvedTipLoadStrip(alongY)
436            curvature = mbs.GetObjectOutputBody(built['elements'][0], exu.OutputVariableType.CurvatureLocal, localPosition=[0, 0, 0])
437            curvatures.append(curvature[1 if alongY else 0])
438        print(f'\n    curvature along x: {curvatures[0]:+.4e}, along y: {curvatures[1]:+.4e}', end='')
439        self.assertEqual(np.sign(curvatures[0]), np.sign(curvatures[1]))
440        self.assertLess(curvatures[0], 0.)
441
442
443class TestShellsModule(unittest.TestCase):
444    """exudyn.shells: ShellMesh, surface maps and thickness functions."""
445
446    def testShellMeshTopology(self):
447        """3 x 2 elements: 12 nodes, 6 elements, edge node lists, node positions on the grid."""
448        systemContainer = exu.SystemContainer()
449        mbs = systemContainer.AddSystem()
450        numberOfElementsX, numberOfElementsY, lengthX, lengthY = 3, 2, 0.6, 0.4
451        origin = np.array([0.1, -0.2, 0.3])
452        plate = ShellMesh(vertices=[origin, origin + [lengthX, 0, 0], origin + [lengthX, lengthY, 0], origin + [0, lengthY, 0]],
453                          numberOfElementsX=numberOfElementsX, numberOfElementsY=numberOfElementsY,
454                          youngsModulus=youngsModulus, poissonsRatio=0.3, density=density, thickness=stripThickness)
455        plate.CreateANCFThinPlateElements(mbs)
456        print(f'\n    nodes {len(plate.nodeNumbers)}, elements {len(plate.elementNumbers)}, edge nodes '
457              f'{ {key: len(value) for key, value in plate.boundaryNodeNumbers.items()} }', end='')
458        self.assertEqual(len(plate.nodeNumbers), (numberOfElementsX + 1) * (numberOfElementsY + 1))
459        self.assertEqual(len(plate.elementNumbers), numberOfElementsX * numberOfElementsY)
460        self.assertEqual(len(plate.boundaryNodeNumbers['left']), numberOfElementsY + 1)
461        self.assertEqual(len(plate.boundaryNodeNumbers['bottom']), numberOfElementsX + 1)
462        for indexY in range(numberOfElementsY + 1):
463            for indexX in range(numberOfElementsX + 1):
464                nodeNumber = plate.nodeNumbers[indexY * (numberOfElementsX + 1) + indexX]
465                position = np.array(mbs.GetNodeParameter(nodeNumber, 'referenceCoordinates'))[0:3]
466                expected = origin + [indexX * lengthX / numberOfElementsX, indexY * lengthY / numberOfElementsY, 0]
467                self.assertLess(np.linalg.norm(position - expected), 1e-12)
468
469    def testHemisphericalMapNodesOnSphere(self):
470        """SurfaceMap.HemisphericalShell: all nodes on the sphere of radius R, both slope vectors tangent to it."""
471        systemContainer = exu.SystemContainer()
472        mbs = systemContainer.AddSystem()
473        radius = 2.0
474        plate = ShellMesh(vertices=[[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], numberOfElementsX=4, numberOfElementsY=4,
475                          youngsModulus=youngsModulus, poissonsRatio=0.3, density=density, thickness=stripThickness,
476                          surfaceMap=SurfaceMap.HemisphericalShell(1., 1., radius, np.pi / 3))
477        plate.CreateANCFThinPlateElements(mbs)
478        radiusError, normalSlopeComponent = 0., 0.
479        for nodeNumber in plate.nodeNumbers:
480            referenceCoordinates = np.array(mbs.GetNodeParameter(nodeNumber, 'referenceCoordinates'))
481            position = referenceCoordinates[0:3]
482            radiusError = max(radiusError, abs(np.linalg.norm(position) - radius))
483            for slope in [referenceCoordinates[3:6], referenceCoordinates[6:9]]:
484                if np.linalg.norm(slope) > 0:       #degenerate slope at the pole
485                    normalSlopeComponent = max(normalSlopeComponent, abs(slope @ position) / (np.linalg.norm(slope) * radius))
486        print(f'\n    radius error {radiusError:.2e}, normal slope component {normalSlopeComponent:.2e}', end='')
487        self.assertLess(radiusError, 1e-12)
488        self.assertLess(normalSlopeComponent, 1e-12)
489
490    def testThicknessFunctionOfARotatedStrip(self):
491        """A strip turned by 30 degrees in the x-y plane with the thickness h0 (1 - s/(2L)) along its axis s, given as a
492        function of the global x and y: the gradients along the node slopes give 12 thickness values per element, and
493        the tip deflection is the one of the tapered cantilever (#2864)."""
494        angle = np.pi / 6
495        axis, across = np.array([np.cos(angle), np.sin(angle), 0.]), np.array([-np.sin(angle), np.cos(angle), 0.])
496        def ThicknessFunction(x, y):
497            return stripThickness * (1 - 0.5 * (x * axis[0] + y * axis[1]) / stripLength)
498        systemContainer = exu.SystemContainer()
499        mbs = systemContainer.AddSystem()
500        plate = ShellMesh(vertices=[[0, 0, 0], stripLength * axis, stripLength * axis + stripWidth * across, stripWidth * across],
501                          numberOfElementsX=8, numberOfElementsY=1, youngsModulus=youngsModulus, poissonsRatio=poissonRatio,
502                          density=density, thickness=stripThickness, thicknessFunction=ThicknessFunction)
503        plate.CreateANCFThinPlateElements(mbs)
504        self.assertEqual(len(mbs.GetObjectParameter(plate.elementNumbers[0], 'thickness')), 12)
505        for nodeNumber in plate.boundaryNodeNumbers['left']:
506            AddNodeConstraints(mbs, nodeNumber, dofs=list(range(numberOfPlateDOFsPerNode)))
507        tipNodes = plate.boundaryNodeNumbers['right']
508        for nodeNumber in tipNodes:
509            mbs.AddLoad(LoadForceVector(markerNumber=mbs.AddMarker(MarkerNodePosition(nodeNumber=nodeNumber)),
510                                        loadVector=[0, 0, -tipForce / len(tipNodes)]))
511        mbs.Assemble()
512        mbs.SolveStatic(StaticSettings())
513        positions = np.linspace(0, stripLength, 20001)
514        thickness = stripThickness * (1 - 0.5 * positions / stripLength)
515        integrand = tipForce * (stripLength - positions)**2 / (youngsModulus * stripWidth * thickness**3 / 12)
516        reference = np.sum(0.5 * (integrand[1:] + integrand[:-1]) * np.diff(positions))   #trapezoidal rule
517        PrintComparison('tip deflection, thickness function of a rotated strip', TipDeflection(mbs, tipNodes), reference)
518        self.assertAlmostEqual(TipDeflection(mbs, tipNodes) / reference, 1., delta=1e-3)
519
520
521class TestVisualizationSettings(unittest.TestCase):
522    """The settings of visualizationSettings.bodies.shells and their defaults."""
523
524    def testShellVisualizationDefaults(self):
525        """contourZeta, reducedInterpolation, drawNormal, drawNormalFactor, drawNormalColor, drawNormalLines."""
526        shellSettings = exu.SystemContainer().visualizationSettings.bodies.shells
527        self.assertEqual(shellSettings.contourZeta, 1.)
528        self.assertTrue(shellSettings.reducedInterpolation)
529        self.assertFalse(shellSettings.drawNormal)
530        self.assertEqual(shellSettings.drawNormalFactor, 1.)
531        self.assertTrue(np.allclose(list(shellSettings.drawNormalColor), [0.2, 0.2, 0.2, 1.]))
532        self.assertTrue(shellSettings.drawNormalLines)
533
534
535#run all tests; the result is the number of tests that passed
536testIsActive = exu.sys.get('testIsActive', False)
537result = unittest.TextTestRunner(verbosity=2 if not testIsActive else 0,
538                                 stream=io.StringIO() if testIsActive else None).run(
539    unittest.TestSuite(unittest.defaultTestLoader.loadTestsFromTestCase(testCase) for testCase in
540                       [TestANCFThinPlateElement, TestANCFThinPlateDamping, TestANCFThinPlateOutputs, TestShellsModule,
541                        TestVisualizationSettings]))
542for (test, trace) in result.failures + result.errors:
543    exu.Print('FAILED: ' + str(test) + '\n' + trace)
544testResult = result.testsRun - len(result.failures) - len(result.errors) - len(result.unexpectedSuccesses)
545exu.Print('solution of ANCFThinPlateRevisionTest=', testResult)
546exu.sys['testResult'] = testResult