feat: adopt lumped mass and reduced-coordinate shell HHT

Integrate Hex8 mass verification and fixed-null-basis shell dynamics with numerical and physics handoff evidence. Preserve interrupted reference and full-validation status; full integration validation is deferred until after performance optimization.
This commit is contained in:
KOKO\Mimi
2026-10-13 16:57:19 +09:00
parent ede137c6b4
commit eaa5cbeb21
110 changed files with 15274 additions and 971 deletions
+64 -20
View File
@@ -48,7 +48,7 @@ double GradientProduct(std::size_t a, std::size_t b, std::size_t i,
if (d != i && d != j) integral *= 1.0 + kSigns[a][d] * kSigns[b][d] / 3.0;
return integral;
}
TEST(Hex8Element, ExactBrickStiffnessAndConsistentMass) {
TEST(Hex8Element, ExactBrickStiffnessAndRowSumLumpedMass) {
auto element = Make();
ASSERT_TRUE(element.HasValue());
auto stiffness = element.Value().ComputeStiffness();
@@ -61,9 +61,6 @@ TEST(Hex8Element, ExactBrickStiffnessAndConsistentMass) {
ASSERT_EQ(m.Columns(), 24U);
for (std::size_t a = 0; a < 8; ++a)
for (std::size_t b = 0; b < 8; ++b) {
double scalar = 2.0 * 48.0 / 216.0;
for (std::size_t d = 0; d < 3; ++d)
scalar *= kSigns[a][d] == kSigns[b][d] ? 2.0 : 1.0;
for (std::size_t i = 0; i < 3; ++i)
for (std::size_t j = 0; j < 3; ++j) {
double exact = -(2.0 / 3.0) * 48.0 * GradientProduct(a, b, i, j) +
@@ -75,7 +72,10 @@ TEST(Hex8Element, ExactBrickStiffnessAndConsistentMass) {
exact += (80.0 / 48.0) * (48.0 * kSigns[a][i] / (8.0 * (i + 1))) *
(48.0 * kSigns[b][j] / (8.0 * (j + 1)));
EXPECT_NEAR(k(3 * a + i, 3 * b + j), exact, 2e-12);
EXPECT_NEAR(m(3 * a + i, 3 * b + j), i == j ? scalar : 0.0, 2e-14);
// rho*V/8 = 2*48/8. This is the row sum of the complete
// consistent mass, before constraints can remove its columns.
const double expected_mass = a == b && i == j ? 12.0 : 0.0;
EXPECT_NEAR(m(3 * a + i, 3 * b + j), expected_mass, 2e-14);
}
}
}
@@ -351,24 +351,21 @@ TEST(Hex8Element, SixRigidModesAndZeroStateTangent) {
}
}
TEST(Hex8Element,
ConsistentMassHasAll24PositiveDirectionsAndReferenceInvariance) {
RowSumLumpedMassHasAll24PositiveDirectionsAndReferenceInvariance) {
auto element = Make();
ASSERT_TRUE(element.HasValue());
auto before = element.Value().ComputeMass().Value().values;
// Tensor-product Walsh vectors diagonalize the independent brick scalar
// mass; eigenvalues are rho*V/(8*3^number_of_odd_axes), each repeated3.
// The complete consistent mass is row-summed before constraints, giving
// rho*V/8 on every translational diagonal of this affine brick.
for (std::size_t mask = 0; mask < 8; ++mask)
for (std::size_t i = 0; i < 3; ++i) {
fesa::Vector v{24U};
unsigned odd = 0U;
for (std::size_t d = 0; d < 3; ++d)
if ((mask & (1U << d)) != 0U) ++odd;
for (std::size_t a = 0; a < 8; ++a) {
v[3 * a + i] = 1.0;
for (std::size_t d = 0; d < 3; ++d)
if ((mask & (1U << d)) != 0U) v[3 * a + i] *= kSigns[a][d];
}
const double eigenvalue = 12.0 / std::pow(3.0, odd);
const double eigenvalue = 12.0;
const auto mv = before.Multiply(v);
for (std::size_t a = 0; a < 24; ++a)
EXPECT_NEAR(mv[a], eigenvalue * v[a], 3e-14);
@@ -839,6 +836,27 @@ std::vector<std::array<double, 2>> LegendreRule(unsigned count) {
}
return points;
}
std::array<double, 8> WarpedLumpedMass3x3() {
const auto rule = LegendreRule(3);
std::array<double, 8> mass{};
for (const auto& xi : rule)
for (const auto& eta : rule)
for (const auto& zeta : rule) {
const std::array<double, 3> natural{xi[0], eta[0], zeta[0]};
// det(dX/d(xi,eta,zeta)) for Warped(), expanded independently from
// the production geometry and quadrature paths.
const double det =
6.0 - .0135 * xi[0] * xi[0] - .036 * eta[0] * eta[0] -
.0324 * zeta[0] * zeta[0] + .00324 * xi[0] * eta[0] * zeta[0];
for (std::size_t a = 0; a < 8; ++a) {
double shape = .125;
for (std::size_t d = 0; d < 3; ++d)
shape *= 1.0 + kSigns[a][d] * natural[d];
mass[a] += 2.0 * xi[1] * eta[1] * zeta[1] * shape * det;
}
}
return mass;
}
Triple Cross(const Triple& a, const Triple& b) {
return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]};
@@ -911,6 +929,40 @@ std::array<double, 2> WarpedQuadratureEnergies(const fesa::Vector& u,
result[0] += 40.0 * trace_integral * trace_integral / volume;
return result;
}
TEST(Hex8Element,
WarpedRowSumMassMatchesIndependentNodalIntegralsAndNonuniformEnergy) {
auto element = Make(Warped());
ASSERT_TRUE(element.HasValue());
const auto mass = element.Value().ComputeMass();
ASSERT_TRUE(mass.HasValue());
const auto expected = WarpedLumpedMass3x3();
const auto& actual = mass.Value().values;
double total = 0.0, min_nodal = expected[0], max_nodal = expected[0];
fesa::Vector velocity(24);
for (std::size_t a = 0; a < 8; ++a) {
total += expected[a];
min_nodal = (std::min)(min_nodal, expected[a]);
max_nodal = (std::max)(max_nodal, expected[a]);
velocity[3U * a] = static_cast<double>(a + 1U);
for (std::size_t i = 0; i < 3; ++i)
for (std::size_t j = 0; j < 3; ++j)
EXPECT_NEAR(actual(3U * a + i, 3U * a + j),
i == j ? expected[a] : 0.0, 3e-14);
}
// The xyz term in detJ gives two distinct nodal masses. A uniform rho*V/8
// shortcut preserves total mass but fails this kinetic-energy observable.
EXPECT_GT(max_nodal - min_nodal, 1e-4);
const double lumped_energy = .5 * velocity.Dot(actual.Multiply(velocity));
double independent_energy = 0.0, uniform_energy = 0.0;
for (std::size_t a = 0; a < 8; ++a) {
const double v2 = velocity[3U * a] * velocity[3U * a];
independent_energy += .5 * expected[a] * v2;
uniform_energy += .5 * total / 8.0 * v2;
}
EXPECT_NEAR(lumped_energy, independent_energy,
3e-14 * independent_energy);
EXPECT_GT(std::abs(lumped_energy - uniform_energy), 1e-4);
}
TEST(Hex8Element, WarpedNonaffineQuadratureDifferenceIsQuantifiedAndRefines) {
auto element = Make(Warped());
ASSERT_TRUE(element.HasValue());
@@ -918,26 +970,18 @@ TEST(Hex8Element, WarpedNonaffineQuadratureDifferenceIsQuantifiedAndRefines) {
for (std::size_t i = 0; i < 24; ++i)
u[i] = .03 * std::sin(.7 * static_cast<double>(i));
const auto k = element.Value().ComputeStiffness().Value().values;
const auto m = element.Value().ComputeMass().Value().values;
const auto q2 = WarpedQuadratureEnergies(u, 2),
q3 = WarpedQuadratureEnergies(u, 3);
const auto q5 = WarpedQuadratureEnergies(u, 5),
q7 = WarpedQuadratureEnergies(u, 7);
const double budget = 512.0 * std::numeric_limits<double>::epsilon();
EXPECT_NEAR(.5 * u.Dot(k.Multiply(u)), q2[0], budget * q2[0]);
EXPECT_NEAR(.5 * u.Dot(m.Multiply(u)), q2[1], budget * q2[1]);
const double stiffness_discrepancy = std::abs(q2[0] - q7[0]) / q7[0];
const double mass_discrepancy = std::abs(q2[1] - q7[1]) / q7[1];
// Mandated2x2 is retained: higher-order integration gives a distinct warped
// discretization. Rational stiffness integrands converge with refinement.
EXPECT_GT(stiffness_discrepancy, 1e-8);
EXPECT_GT(mass_discrepancy, 1e-8);
EXPECT_LT(std::abs(q5[0] - q7[0]), .05 * std::abs(q3[0] - q7[0]));
// N_a*N_b*detJ has per-axis degree<=4, so order3 integrates mass exactly.
EXPECT_NEAR(q3[1], q7[1], budget * q7[1]);
EXPECT_NEAR(q5[1], q7[1], budget * q7[1]);
std::cout << "warped K2-vs-K7 energy relative=" << stiffness_discrepancy
<< " M2-vs-M7 kinetic relative=" << mass_discrepancy
<< " K3-vs-K7=" << std::abs(q3[0] - q7[0]) / q7[0]
<< " K5-vs-K7=" << std::abs(q5[0] - q7[0]) / q7[0] << '\n';
}