Skip to content

Commit ca7b3f2

Browse files
committed
[2D] Step 1: Rigid-body rotation test
1 parent 4c1fc3d commit ca7b3f2

2 files changed

Lines changed: 179 additions & 2 deletions

File tree

examples/Freefem/MMS/2D/params.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"length": 1.0,
33
"youngModulus": 1e6,
4-
"forceField": "LinearSmallStrainFEMForceField",
4+
"forceField": "CorotationalFEMForceField",
55
"reference": {
6-
"nx": 20,
6+
"nx": 5,
77
"nu": 0.0,
88
"dim": "2d"
99
},
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Rigid-rotation test for the corotational FEM force field.
2+
3+
With a pure rigid-body rotation x = R·X the strain is zero.
4+
so the internal elastic force must disappear.
5+
We expect a corotational force field to give zero force but for a linear small-strain field
6+
to yield f != 0, because it sees the rotation as strain.
7+
8+
Run: python test_rigid_rotation.py
9+
"""
10+
11+
import os
12+
import sys
13+
import numpy as np
14+
import matplotlib.pyplot as plt
15+
16+
import Sofa
17+
import Sofa.Core
18+
import Sofa.Simulation
19+
import SofaRuntime # noqa: F401
20+
21+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22+
from beam import load_params, element_quad, RESULTS_DIR
23+
24+
ANGLE_DEG = 90.0
25+
TRANSLATION = [0.0, 0.0, 0.0]
26+
RATIO_TOL = 1e-6 # corotational |f| must be < RATIO_TOL * linear |f|
27+
N_STEPS = 18 # only used by the commented-out solve-based path
28+
29+
30+
def Rz(theta):
31+
c, s = np.cos(theta), np.sin(theta)
32+
return np.array([[c, -s, 0.0],
33+
[s, c, 0.0],
34+
[0.0, 0.0, 1.0]])
35+
36+
37+
def build_scene(root, L, E, nu, nx, force_field):
38+
root.addObject("RequiredPlugin", pluginName=[
39+
"Elasticity",
40+
"Sofa.Component.Mass",
41+
"Sofa.Component.ODESolver.Forward", # EulerExplicitSolver
42+
"Sofa.Component.StateContainer",
43+
"Sofa.Component.Topology.Container.Grid",
44+
"Sofa.Component.Topology.Container.Dynamic",
45+
# TODO: Revisit when testing equilibrium
46+
# "Sofa.Component.Constraint.Projective",
47+
# "Sofa.Component.Engine.Select",
48+
# "Sofa.Component.LinearSolver.Iterative",
49+
# "Sofa.Component.ODESolver.Backward",
50+
])
51+
root.addObject("DefaultAnimationLoop")
52+
root.gravity = [0.0, 0.0, 0.0]
53+
54+
grid = root.addChild("Grid")
55+
grid.addObject("RegularGridTopology", name="grid", nx=nx, ny=nx, nz=1,
56+
min=[0.0, 0.0, 0.0], max=[L, L, 0.0])
57+
58+
with root.addChild("Beam") as beam:
59+
# An ODE solver. TODO: Revisit when testing equilibrium
60+
beam.addObject("EulerExplicitSolver", name="odeSolver")
61+
62+
# TODO: An attempt to demonstrate inconsistencies through a solver
63+
# Cannot work for now. It should be revisited.
64+
# beam.addObject("StaticSolver", name="staticSolver", printLog=False)
65+
# beam.addObject("NewtonRaphsonSolver", name="newton",
66+
# maxNbIterationsNewton=25,
67+
# absoluteResidualStoppingThreshold=1e-10, printLog=False)
68+
# beam.addObject("CGLinearSolver", name="linearSolver",
69+
# iterations=5000, tolerance=1e-12, threshold=1e-12)
70+
# -------------------------------------------------------------------------
71+
72+
dofs = beam.addObject("MechanicalObject", name="dofs", template="Vec3d",
73+
position="@../Grid/grid.position")
74+
beam.addObject("UniformMass", name="mass", totalMass=1.0)
75+
element_quad.add_topology(beam)
76+
beam.addObject(force_field, name="FEM", template="Vec3d",
77+
youngModulus=E, poissonRatio=nu, topology="@topology")
78+
79+
# TODO: An attempt to demonstrate inconsistencies through a solver. These are the BCs.
80+
# Cannot work for now. It should be revisited.
81+
# eps = 1e-5
82+
# beam.addObject("BoxROI", name="boundary", template="Vec3d", drawBoxes=False,
83+
# box=[[-eps, -eps, -1.0, eps, L + eps, 1.0], # x = 0
84+
# [L - eps, -eps, -1.0, L + eps, L + eps, 1.0], # x = L
85+
# [-eps, -eps, -1.0, L + eps, eps, 1.0], # y = 0
86+
# [-eps, L - eps, -1.0, L + eps, L + eps, 1.0]])# y = L
87+
# beam.addObject("AffineMovementProjectiveConstraint",
88+
# meshIndices=list(range(nx * nx)),
89+
# indices="@boundary.indices",
90+
# beginConstraintTime=0.0, endConstraintTime=float(N_STEPS),
91+
# rotation=Rz(np.radians(ANGLE_DEG)).tolist(),
92+
# translation=TRANSLATION)
93+
return dofs
94+
95+
96+
def run(force_field):
97+
"""Impose x = R·X and return (rest X, rotated config, max internal force magnitude)."""
98+
cfg = load_params()
99+
L, E = cfg["length"], cfg["youngModulus"]
100+
nu, nx = cfg["reference"]["nu"], cfg["reference"]["nx"]
101+
102+
root = Sofa.Core.Node("root")
103+
dofs = build_scene(root, L, E, nu, nx, force_field)
104+
Sofa.Simulation.init(root)
105+
106+
# Take the rest positions and apply x = R @ X
107+
# This will apply the rigid-body rotation to all the dofs
108+
X = dofs.rest_position.array().copy()
109+
Xr = (Rz(np.radians(ANGLE_DEG)) @ X.T).T + np.array(TRANSLATION)
110+
with dofs.position.writeable() as p:
111+
p[:] = Xr
112+
113+
# Animate the scene once. What this does is call addForce, evaluated at x = R·X at the start of the step.
114+
# force is read right after and is the internal force of the pure rotation.
115+
# In the case of LinearSmallStrainFEMForceField, we expect to see an emergent force, resulting
116+
# from the force field understanding the rotation as deformation.
117+
# In the case of CorotationalFEMForceField, we expect to see zero force, resulting from the
118+
# force field understanding the displacement as a rigid-body rotation causing zero strain.
119+
Sofa.Simulation.animate(root, 1e-8)
120+
f = dofs.force.array().copy()
121+
Sofa.Simulation.unload(root)
122+
123+
return X, Xr, float(np.max(np.linalg.norm(f, axis=1)))
124+
125+
126+
def write_metrics(forces, path):
127+
''' Write the measured forces to a file'''
128+
f_cr = forces["CorotationalFEMForceField"]
129+
f_lin = forces["LinearSmallStrainFEMForceField"]
130+
with open(path, "w") as f:
131+
f.write(f"Rigid-rotation internal force angle={ANGLE_DEG} deg "
132+
f"translation={TRANSLATION}\n")
133+
f.write("=" * 74 + "\n")
134+
for ff, fmax in forces.items():
135+
f.write(f" {ff:32s} max |internal force| = {fmax:.6e}\n")
136+
ratio = f_cr / f_lin if f_lin != 0 else float("nan")
137+
f.write(f"\n corotational / linear ratio = {ratio:.3e} "
138+
f"(rigid-body property holds if << 1)\n")
139+
140+
141+
def plot_forces(X, Xr, forces, path):
142+
''' Plot the forces throughout the 2D plane'''
143+
fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(11, 5))
144+
145+
ax0.scatter(X[:, 0], X[:, 1], s=12, c="lightgray", label="rest X")
146+
ax0.scatter(Xr[:, 0], Xr[:, 1], s=12, c="tab:red", label="imposed R·X")
147+
ax0.set_aspect("equal"); ax0.set_xlabel("x"); ax0.set_ylabel("y")
148+
ax0.set_title(f"imposed rigid rotation {ANGLE_DEG}°")
149+
ax0.legend(loc="best"); ax0.grid(True, alpha=0.3)
150+
151+
names = list(forces.keys())
152+
vals = [max(forces[n], 1e-30) for n in names] # floor for log scale
153+
ax1.bar(range(len(names)), vals, color=["tab:green", "tab:red"])
154+
ax1.set_yscale("log")
155+
ax1.set_xticks(range(len(names)))
156+
ax1.set_xticklabels([n.replace("FEMForceField", "") for n in names])
157+
ax1.set_ylabel("max |internal force|")
158+
ax1.set_title("internal force under rigid rotation\n(corotational must be ~0)")
159+
ax1.grid(True, axis="y", alpha=0.3)
160+
161+
fig.tight_layout()
162+
fig.savefig(path, dpi=150)
163+
plt.close(fig)
164+
165+
166+
if __name__ == "__main__":
167+
forces = {}
168+
geom = None
169+
for ff in ("CorotationalFEMForceField", "LinearSmallStrainFEMForceField"):
170+
X, Xr, fmax = run(ff)
171+
forces[ff] = fmax
172+
geom = (X, Xr)
173+
print(f"{ff:32s} max |internal force| = {fmax:.6e}")
174+
175+
os.makedirs(RESULTS_DIR, exist_ok=True)
176+
write_metrics(forces, os.path.join(RESULTS_DIR, "rigid_rotation_metrics.txt"))
177+
plot_forces(*geom, forces, os.path.join(RESULTS_DIR, "rigid_rotation_force.png"))

0 commit comments

Comments
 (0)