diff --git a/src/fesa/assembly/load_assembler.cpp b/src/fesa/assembly/load_assembler.cpp index 695e911..3f0bb83 100644 --- a/src/fesa/assembly/load_assembler.cpp +++ b/src/fesa/assembly/load_assembler.cpp @@ -17,6 +17,7 @@ namespace fesa { namespace { constexpr std::size_t dofsPerNode = 6U; +constexpr double shellMomentProjectionTolerance = 1.0e-12; Status loadFailure( const std::string& code, @@ -215,6 +216,70 @@ Status validateFiniteVector( return Status::ok(); } +Status validateShellMoments( + const Domain& domain, + const Vector& fullLoad) { + if (domain.shellElements().empty()) { + return Status::ok(); + } + + std::vector frameByNode( + domain.nodes().size(), nullptr); + for (const auto& frame : domain.shellNodeInitialFrames()) { + if (frame.nodeIndex >= frameByNode.size() || + frameByNode[frame.nodeIndex] != nullptr) { + return loadFailure( + "invalid-shell-director", + {domain.sourcePath(), 0U}, + "NODE", + std::to_string(frame.nodeIndex), + "Shell nodal directors must have unique in-range node identities."); + } + frameByNode[frame.nodeIndex] = &frame; + } + + for (std::size_t node = 0U; node < domain.nodes().size(); ++node) { + const double momentX = fullLoad[node * dofsPerNode + 3U]; + const double momentY = fullLoad[node * dofsPerNode + 4U]; + const double momentZ = fullLoad[node * dofsPerNode + 5U]; + if (momentX == 0.0 && momentY == 0.0 && momentZ == 0.0) { + continue; + } + + const auto* const frame = frameByNode[node]; + if (frame == nullptr) { + return loadFailure( + "invalid-shell-director", + domain.nodes()[node].location, + "NODE", + domain.nodes()[node].sourceId.sourceLabelText, + "A loaded shell node must have an approved initial director."); + } + + const double momentScale = std::max( + std::abs(momentX), + std::max(std::abs(momentY), std::abs(momentZ))); + const double scaledX = momentX / momentScale; + const double scaledY = momentY / momentScale; + const double scaledZ = momentZ / momentScale; + const double scaledNorm = std::hypot(scaledX, scaledY, scaledZ); + const double scaledDot = + frame->director[0U] * scaledX + + frame->director[1U] * scaledY + + frame->director[2U] * scaledZ; + const double projectionRatio = std::abs(scaledDot) / scaledNorm; + if (!(projectionRatio <= shellMomentProjectionTolerance)) { + return loadFailure( + "unsupported-drilling-load", + domain.nodes()[node].location, + "CLOAD", + domain.nodes()[node].sourceId.sourceLabelText, + "The aggregate nodal moment has an unsupported director-parallel component."); + } + } + return Status::ok(); +} + } // namespace Result LoadAssembler::assembleFullNodalLoad( @@ -328,6 +393,10 @@ Result LoadAssembler::assembleFullNodalLoad( fullLoad[fullDof] = accumulated; } } + const Status shellMomentStatus = validateShellMoments(domain, fullLoad); + if (!shellMomentStatus.isOk()) { + return Result::failure(shellMomentStatus); + } return Result::success(std::move(fullLoad)); } diff --git a/tests/unit/assembly/load_assembler_test.cpp b/tests/unit/assembly/load_assembler_test.cpp index 241d702..be87014 100644 --- a/tests/unit/assembly/load_assembler_test.cpp +++ b/tests/unit/assembly/load_assembler_test.cpp @@ -76,6 +76,69 @@ LoadFixture makeFixture( return {std::move(domain), std::move(model), std::move(dofs)}; } +LoadFixture makeShellFixture( + std::vector boundaries, + std::vector loads) { + const std::filesystem::path source{"models/shell-load-assembly.inp"}; + fesa::ModelDefinition definition{}; + definition.sourcePath = source; + definition.sourceContentIdentity = "fnv1a64:1122334455667788"; + definition.nodes = { + {{"Shell-1", 10, "10"}, {0.0, 0.0, 0.0}, {source, 2U}}, + {{"Shell-1", 20, "20"}, {1.0, 0.0, 0.0}, {source, 3U}}, + {{"Shell-1", 30, "30"}, {1.0, 1.0, 0.0}, {source, 4U}}, + {{"Shell-1", 40, "40"}, {0.0, 1.0, 0.0}, {source, 5U}}}; + definition.materials = { + {"Material", 1000.0, 0.25, {source, 6U}}}; + definition.shellSections = { + {"ShellSection", 0.1, 0U, {source, 7U}}}; + definition.shellElements = {{ + {"Shell-1", 1, "1"}, + fesa::ShellSourceElementType::s4, + {0U, 1U, 2U, 3U}, + 0U, + 0U, + {source, 8U}}}; + for (std::size_t node = 0U; node < definition.nodes.size(); ++node) { + definition.shellNodeInitialFrames.push_back({ + static_cast(node), + {0.0, 0.0, 1.0}, + {1.0, 0.0, 0.0}, + {0.0, 1.0, 0.0}}); + } + definition.steps = {{ + "Step-1", + std::move(boundaries), + std::move(loads), + 0.1, + 1.0, + 0.01, + 1.0, + {source, 20U}}}; + + auto domainResult = fesa::Domain::create(std::move(definition)); + if (!domainResult.hasValue()) { + throw std::runtime_error{"Shell load fixture Domain construction failed."}; + } + auto domain = std::make_unique( + std::move(domainResult.value())); + + auto modelResult = fesa::AnalysisModel::create(*domain); + if (!modelResult.hasValue()) { + throw std::runtime_error{"Shell load fixture AnalysisModel construction failed."}; + } + auto model = std::make_unique( + std::move(modelResult.value())); + + auto dofResult = fesa::DofManager::create(*model); + if (!dofResult.hasValue()) { + throw std::runtime_error{"Shell load fixture DofManager construction failed."}; + } + auto dofs = std::make_unique( + std::move(dofResult.value())); + return {std::move(domain), std::move(model), std::move(dofs)}; +} + fesa::SparseMatrix makeDenseSparse( const std::size_t rows, const std::size_t columns, @@ -174,6 +237,100 @@ TEST(LoadAssembly, AccumulatesSignedLoadsInSourceOrder) { EXPECT_DOUBLE_EQ(second.value()[0U], 0.0); } +// MITC4-LOAD-001 +TEST(LoadAssembly, AggregatesAllSixGlobalShellLoadComponentsInSourceOrder) { + const std::filesystem::path source{"models/shell-load-assembly.inp"}; + auto fixture = makeShellFixture( + {}, + {{"10", 1, 1.0e16, {source, 30U}}, + {"10", 1, -1.0e16, {source, 31U}}, + {"10", 1, 1.0, {source, 32U}}, + {"10", 2, 2.0, {source, 33U}}, + {"10", 3, 3.0, {source, 34U}}, + {"10", 4, 1.0e16, {source, 35U}}, + {"10", 4, -1.0e16, {source, 36U}}, + {"10", 4, 4.0, {source, 37U}}, + {"10", 5, 5.0, {source, 38U}}, + {"10", 6, 6.0, {source, 39U}}, + {"10", 6, -6.0, {source, 40U}}}); + + const auto result = fesa::LoadAssembler::assembleFullNodalLoad( + *fixture.model, *fixture.dofs); + + ASSERT_TRUE(result.hasValue()); + ASSERT_EQ(result.value().size(), 24U); + EXPECT_EQ( + std::vector(result.value().data(), result.value().data() + 6U), + (std::vector{1.0, 2.0, 3.0, 4.0, 5.0, 0.0})); +} + +// MITC4-LOAD-002 +TEST(LoadAssembly, AcceptsExactlyZeroAggregateShellMoment) { + const std::filesystem::path source{"models/shell-load-assembly.inp"}; + auto fixture = makeShellFixture( + {}, + {{"10", 4, 3.0, {source, 30U}}, + {"10", 4, -3.0, {source, 31U}}, + {"10", 5, 4.0, {source, 32U}}, + {"10", 5, -4.0, {source, 33U}}, + {"10", 6, 5.0, {source, 34U}}, + {"10", 6, -5.0, {source, 35U}}}); + + const auto result = fesa::LoadAssembler::assembleFullNodalLoad( + *fixture.model, *fixture.dofs); + + ASSERT_TRUE(result.hasValue()); + EXPECT_DOUBLE_EQ(result.value()[3U], 0.0); + EXPECT_DOUBLE_EQ(result.value()[4U], 0.0); + EXPECT_DOUBLE_EQ(result.value()[5U], 0.0); +} + +// MITC4-LOAD-003 +TEST(LoadAssembly, EnforcesAggregateShellMomentDirectorProjectionThreshold) { + const std::filesystem::path source{"models/shell-load-assembly.inp"}; + auto acceptedFixture = makeShellFixture( + {}, + {{"10", 4, 1.0, {source, 30U}}, + {"10", 6, 1.0e-12, {source, 31U}}}); + auto rejectedFixture = makeShellFixture( + {}, + {{"10", 4, 1.0, {source, 30U}}, + {"10", 6, 2.0e-12, {source, 31U}}}); + + const auto accepted = fesa::LoadAssembler::assembleFullNodalLoad( + *acceptedFixture.model, *acceptedFixture.dofs); + const auto rejected = fesa::LoadAssembler::assembleFullNodalLoad( + *rejectedFixture.model, *rejectedFixture.dofs); + + ASSERT_TRUE(accepted.hasValue()); + ASSERT_FALSE(rejected.hasValue()); + EXPECT_EQ(rejected.status().failureCategory(), fesa::FailureCategory::model); + ASSERT_EQ(rejected.status().diagnostics().size(), 1U); + EXPECT_EQ( + rejected.status().diagnostics()[0U].code, + "unsupported-drilling-load"); + EXPECT_EQ(rejected.status().diagnostics()[0U].keyword, "CLOAD"); + EXPECT_EQ(rejected.status().diagnostics()[0U].entityIdentity, "10"); +} + +// MITC4-LOAD-004 +TEST(LoadAssembly, RejectsDrillingMomentBeforeEffectiveRhsCanBeFormed) { + const std::filesystem::path source{"models/shell-load-assembly.inp"}; + auto fixture = makeShellFixture( + {{"10", 1, 1, 2.0, {source, 21U}}}, + {{"10", 6, 1.0, {source, 30U}}}); + + const auto rejected = fesa::LoadAssembler::assembleFullNodalLoad( + *fixture.model, *fixture.dofs); + + ASSERT_FALSE(rejected.hasValue()); + EXPECT_EQ(rejected.status().failureCategory(), fesa::FailureCategory::model); + ASSERT_EQ(rejected.status().diagnostics().size(), 1U); + EXPECT_EQ( + rejected.status().diagnostics()[0U].code, + "unsupported-drilling-load"); +} + TEST(LoadAssembly, FormsNonzeroPrescribedEffectiveRhs) { const std::filesystem::path source{"models/load-assembly.inp"}; auto fixture = makeFixture(