#include "fesa/analysis/hht_dynamic_procedure.h" #include #include #include #include #include #include #include #include #include #include #include "fesa/analysis/newton_iteration_log.h" #include "fesa/analysis/analysis_model.h" #include "analysis/hht_dynamic_internal.h" #include "analysis/hht_reduced_integrator.h" #include "analysis/hht_reduced_procedure.h" #include "analysis/equilibrium_algorithm.h" #include "fesa/assembly/parallel_for.h" #include "fesa/solvers/linear/mkl_pardiso_solver.h" #include "fesa/solvers/linear/mkl_pardiso_tangent_solver.h" namespace { fesa::Status InjectedTangentFailure() { return fesa::Status::Failure( fesa::FailureCategory::kSolver, {{fesa::Severity::kError, "injected-tangent-backend", {}, "DYNAMIC", {}, "Injected tangent backend failure."}}); } class FailingTangent final : public fesa::TangentSolver { public: fesa::Status FactorizeFresh(const fesa::SparseMatrix&) override { return InjectedTangentFailure(); } fesa::Status SolveCorrection(const fesa::Vector&, fesa::Vector&) const override { return InjectedTangentFailure(); } }; std::string FailureText(const fesa::Status& status) { std::string result; for (const auto& diagnostic : status.Diagnostics()) { if (!result.empty()) result += " | "; result += diagnostic.code + ": " + diagnostic.message; } return result; } fesa::ModelDefinition MakeDefinition(bool fixed = true); fesa::ModelDefinition MakeRigidTranslationDefinition() { auto definition = MakeDefinition(false); const std::filesystem::path source{"dynamic-state.inp"}; definition.nodes.insert( definition.nodes.end(), {{{"Shell-B", 50, "0050"}, {2.0, 0.0, 0.0}, {source, 14U}}, {{"Shell-B", 60, "0060"}, {3.0, 0.0, 0.0}, {source, 15U}}, {{"Shell-B", 70, "0070"}, {3.0, 1.0, 0.0}, {source, 16U}}, {{"Shell-B", 80, "0080"}, {2.0, 1.0, 0.0}, {source, 17U}}}); definition.shell_elements.push_back( {{"Shell-B", 19, "0019"}, fesa::ShellSourceElementType::kS4, {4U, 5U, 6U, 7U}, 0U, 0U, {source, 18U}}); for (std::size_t node = 4U; node < 8U; ++node) { definition.shell_node_initial_frames.push_back( {static_cast(node), {0.0, 0.0, 1.0}, {1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}}); } definition.node_sets[0U] = {"All-A", std::string{"Shell-A"}, {0U, 1U, 2U, 3U}, {source, 9U}}; definition.node_sets.push_back( {"All-B", std::string{"Shell-B"}, {4U, 5U, 6U, 7U}, {source, 19U}}); definition.dynamic_step->loads = {{"All-A", 1, 1.0, {source, 20U}}, {"All-B", 1, 1.0, {source, 21U}}}; return definition; } std::array RotateGlobal(const std::array& value) { constexpr double kCosZ = 0.7648421872844885; constexpr double kSinZ = 0.6442176872376910; constexpr double kCosX = 0.9210609940028851; constexpr double kSinX = 0.3894183423086505; return {kCosZ * value[0U] - kSinZ * kCosX * value[1U] + kSinZ * kSinX * value[2U], kSinZ * value[0U] + kCosZ * kCosX * value[1U] - kCosZ * kSinX * value[2U], kSinX * value[1U] + kCosX * value[2U]}; } fesa::ModelDefinition MakeRotatedRigidTranslationDefinition() { auto definition = MakeRigidTranslationDefinition(); for (auto& node : definition.nodes) node.coordinates = RotateGlobal(node.coordinates); for (auto& frame : definition.shell_node_initial_frames) { frame.director = RotateGlobal(frame.director); frame.tangent_a = RotateGlobal(frame.tangent_a); frame.tangent_b = RotateGlobal(frame.tangent_b); } const auto load = RotateGlobal({1.0, 0.0, 0.0}); const std::filesystem::path source{"dynamic-state.inp"}; definition.dynamic_step->loads = {{"All-A", 1, load[0U], {source, 22U}}, {"All-A", 2, load[1U], {source, 23U}}, {"All-A", 3, load[2U], {source, 24U}}, {"All-B", 1, load[0U], {source, 25U}}, {"All-B", 2, load[1U], {source, 26U}}, {"All-B", 3, load[2U], {source, 27U}}}; return definition; } fesa::ModelDefinition MakeFiniteRotationDefinition() { auto definition = MakeDefinition(false); definition.dynamic_step->initial_increment = 0.01; definition.dynamic_step->time_period = 0.02; definition.dynamic_step->increment_count = 2U; definition.dynamic_step->loads = { {"Loaded", 3, 1.0, {"dynamic-state.inp", 23U}}}; return definition; } fesa::ModelDefinition MakeRotatedFiniteRotationDefinition() { auto definition = MakeFiniteRotationDefinition(); for (auto& node : definition.nodes) node.coordinates = RotateGlobal(node.coordinates); for (auto& frame : definition.shell_node_initial_frames) { frame.director = RotateGlobal(frame.director); frame.tangent_a = RotateGlobal(frame.tangent_a); frame.tangent_b = RotateGlobal(frame.tangent_b); } const auto load = RotateGlobal({0.0, 0.0, 1.0}); const std::filesystem::path source{"dynamic-state.inp"}; definition.dynamic_step->loads = {{"Loaded", 1, load[0U], {source, 24U}}, {"Loaded", 2, load[1U], {source, 25U}}, {"Loaded", 3, load[2U], {source, 26U}}}; return definition; } struct RigidTranslationRun { fesa::DynamicExecutionResult execution; std::string log; }; void ExpectSameSourceIds(const std::vector& left, const std::vector& right) { ASSERT_EQ(left.size(), right.size()); for (std::size_t i = 0U; i < left.size(); ++i) { EXPECT_EQ(left[i].instance_name, right[i].instance_name); EXPECT_EQ(left[i].source_label, right[i].source_label); EXPECT_EQ(left[i].source_label_text, right[i].source_label_text); } } fesa::Result RunRigidTranslation( const fesa::ModelDefinition& definition, const fesa::ParallelFor& parallel, const std::string& label) { auto domain = fesa::Domain::Create(definition); if (!domain.HasValue()) return fesa::Result::Failure( domain.GetStatus()); fesa::MklPardisoSolver mass; fesa::MklPardisoTangentSolver tangent; fesa::NewtonIterationLogWriter log; const auto path = std::filesystem::temp_directory_path() / ("fesa-hht-rigid-" + label + ".log"); auto status = log.Open(path); if (!status.IsOk()) return fesa::Result::Failure(status); auto result = fesa::HhtDynamicProcedureExecutor(parallel, mass, tangent) .Execute(domain.Value(), log); status = log.Close(); std::string log_text; { std::ifstream input{path, std::ios::binary}; log_text.assign(std::istreambuf_iterator{input}, {}); } std::filesystem::remove(path); if (!status.IsOk()) return fesa::Result::Failure(status); if (!result.HasValue()) return fesa::Result::Failure( result.GetStatus()); return fesa::Result::Success( {std::move(result.Value()), log_text}); } void ExpectSameDynamicHistory(const RigidTranslationRun& left, const RigidTranslationRun& right) { EXPECT_EQ(left.log, right.log); const auto& left_frames = left.execution.state.AcceptedFrames(); const auto& right_frames = right.execution.state.AcceptedFrames(); ASSERT_EQ(left_frames.size(), right_frames.size()); for (std::size_t frame = 0U; frame < left_frames.size(); ++frame) { const auto& lhs = left_frames[frame]; const auto& rhs = right_frames[frame]; EXPECT_EQ(lhs.time, rhs.time); EXPECT_EQ(lhs.iteration_count, rhs.iteration_count); EXPECT_EQ(lhs.nodal_internal_order, rhs.nodal_internal_order); ExpectSameSourceIds(lhs.nodal_source_ids, rhs.nodal_source_ids); EXPECT_EQ(lhs.nodal_triad, rhs.nodal_triad); EXPECT_EQ(lhs.nodal_director, rhs.nodal_director); for (const auto* values : {&lhs.generalized_coordinates, &lhs.velocity, &lhs.acceleration, &lhs.algorithmic_acceleration, &lhs.inertia_force, &lhs.internal_force, &lhs.external_force, &lhs.residual, &lhs.reaction}) { const auto* other = values == &lhs.generalized_coordinates ? &rhs.generalized_coordinates : values == &lhs.velocity ? &rhs.velocity : values == &lhs.acceleration ? &rhs.acceleration : values == &lhs.algorithmic_acceleration ? &rhs.algorithmic_acceleration : values == &lhs.inertia_force ? &rhs.inertia_force : values == &lhs.internal_force ? &rhs.internal_force : values == &lhs.external_force ? &rhs.external_force : values == &lhs.residual ? &rhs.residual : &rhs.reaction; ASSERT_EQ(values->Size(), other->Size()); for (std::size_t i = 0U; i < values->Size(); ++i) EXPECT_DOUBLE_EQ((*values)[i], (*other)[i]); } EXPECT_DOUBLE_EQ(lhs.kinetic_energy, rhs.kinetic_energy); EXPECT_DOUBLE_EQ(lhs.physical_strain_energy, rhs.physical_strain_energy); EXPECT_DOUBLE_EQ(lhs.drilling_energy, rhs.drilling_energy); EXPECT_DOUBLE_EQ(lhs.external_work, rhs.external_work); EXPECT_DOUBLE_EQ(lhs.energy_defect, rhs.energy_defect); } } void ExpectRotatedDofVector(const fesa::Vector& source, const fesa::Vector& target, const double scale) { ASSERT_EQ(source.Size(), target.Size()); ASSERT_EQ(source.Size() % 6U, 0U); for (std::size_t node = 0U; node < source.Size() / 6U; ++node) for (const std::size_t offset : {0U, 3U}) { const auto rotated = RotateGlobal( {source[6U * node + offset], source[6U * node + offset + 1U], source[6U * node + offset + 2U]}); for (std::size_t component = 0U; component < 3U; ++component) EXPECT_LE(std::abs(rotated[component] - target[6U * node + offset + component]) / scale, 1e-10); } } void ExpectCovariantDynamicHistory(const RigidTranslationRun& original, const RigidTranslationRun& rotated, const double time_scale, const double reference_energy) { constexpr double kCoordinateScale = 1.0; const double velocity_scale = kCoordinateScale / time_scale; const double acceleration_scale = kCoordinateScale / (time_scale * time_scale); const auto& original_frames = original.execution.state.AcceptedFrames(); const auto& rotated_frames = rotated.execution.state.AcceptedFrames(); ASSERT_EQ(original_frames.size(), rotated_frames.size()); for (std::size_t frame = 0U; frame < original_frames.size(); ++frame) { const auto& source = original_frames[frame]; const auto& target = rotated_frames[frame]; ExpectSameSourceIds(source.nodal_source_ids, target.nodal_source_ids); EXPECT_LE(std::abs(source.kinetic_energy - target.kinetic_energy) / reference_energy, 1e-10); EXPECT_LE(std::abs(source.physical_strain_energy - target.physical_strain_energy) / reference_energy, 1e-10); EXPECT_LE(std::abs(source.drilling_energy - target.drilling_energy) / reference_energy, 1e-10); ExpectRotatedDofVector(source.generalized_coordinates, target.generalized_coordinates, kCoordinateScale); ExpectRotatedDofVector(source.velocity, target.velocity, velocity_scale); ExpectRotatedDofVector(source.acceleration, target.acceleration, acceleration_scale); ExpectRotatedDofVector(source.algorithmic_acceleration, target.algorithmic_acceleration, acceleration_scale); for (const auto* values : {&source.inertia_force, &source.internal_force, &source.external_force, &source.residual, &source.reaction}) { const auto* other = values == &source.inertia_force ? &target.inertia_force : values == &source.internal_force ? &target.internal_force : values == &source.external_force ? &target.external_force : values == &source.residual ? &target.residual : &target.reaction; ExpectRotatedDofVector(*values, *other, 1.0); } for (std::size_t node = 0U; node < source.nodal_triad.size(); ++node) for (std::size_t row = 0U; row < 3U; ++row) { const auto triad = RotateGlobal(source.nodal_triad[node][row]); const auto director = RotateGlobal(source.nodal_director[node]); for (std::size_t component = 0U; component < 3U; ++component) { EXPECT_LE(std::abs(triad[component] - target.nodal_triad[node][row][component]), 1e-10); EXPECT_LE(std::abs(director[component] - target.nodal_director[node][component]), 1e-10); } } } } fesa::ModelDefinition MakeDefinition(const bool fixed) { const std::filesystem::path source{"dynamic-state.inp"}; fesa::ModelDefinition definition{}; definition.source_path = source; definition.source_content_identity = "hht-output-unit-fixture"; definition.nodes = {{{"Shell-A", 10, "0010"}, {0.0, 0.0, 0.0}, {source, 1U}}, {{"Shell-A", 20, "20"}, {1.0, 0.0, 0.0}, {source, 2U}}, {{"Shell-A", 30, "30"}, {1.0, 1.0, 0.0}, {source, 3U}}, {{"Shell-A", 40, "40"}, {0.0, 1.0, 0.0}, {source, 4U}}}; definition.materials = {{"Material", 1000.0, 0.25, {source, 5U}}}; definition.densities = {{0U, 2.0, {source, 6U}}}; definition.shell_sections = {{"Shell", 0.1, 0U, {source, 7U}}}; definition.shell_elements = {{{"Shell-A", 9, "0009"}, fesa::ShellSourceElementType::kS4, {0U, 1U, 2U, 3U}, 0U, 0U, {source, 8U}}}; for (std::size_t node = 0U; node < 4U; ++node) { definition.shell_node_initial_frames.push_back( {static_cast(node), {0.0, 0.0, 1.0}, {1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}}); } definition.node_sets = {{"All", std::nullopt, {0U, 1U, 2U, 3U}, {source, 9U}}, {"Loaded", std::nullopt, {0U}, {source, 10U}}}; fesa::DynamicStepDefinition step{}; step.source_step_name_raw = "Transient Load"; step.source_step_name_canonical = "TRANSIENT LOAD"; step.initial_increment = 0.25; step.time_period = 0.5; step.increment_count = 2U; step.maximum_increments = 3U; if (fixed) { step.boundaries = {{"All", 1, 6, 0.0, {source, 11U}}}; step.loads = {{"Loaded", 3, 4.0, {source, 12U}}}; } definition.dynamic_step = step; return definition; } TEST(HhtDynamicProcedure, ClampedStepLoadHasPhysicalReactionAndZeroMotion) { auto domain = fesa::Domain::Create(MakeDefinition()); ASSERT_TRUE(domain.HasValue()); fesa::SerialParallelFor parallel; fesa::MklPardisoSolver mass; fesa::MklPardisoTangentSolver tangent; fesa::NewtonIterationLogWriter log; const auto path = std::filesystem::temp_directory_path() / "fesa-hht-clamped.log"; ASSERT_TRUE(log.Open(path).IsOk()); const fesa::HhtDynamicProcedureExecutor executor(parallel, mass, tangent); auto result = executor.Execute(domain.Value(), log); ASSERT_TRUE(log.Close().IsOk()); std::filesystem::remove(path); ASSERT_TRUE(result.HasValue()) << (result.HasValue() ? "" : result.GetStatus().Diagnostics().front().message); ASSERT_EQ(result.Value().state.AcceptedFrames().size(), 3U); for (const auto& frame : result.Value().state.AcceptedFrames()) { EXPECT_EQ(frame.time_integration_formulation, fesa::DynamicTimeIntegrationFormulation::kReducedCoordinateHht); EXPECT_EQ(frame.iteration_count, 0U); EXPECT_DOUBLE_EQ(frame.reaction[2U], -4.0); EXPECT_DOUBLE_EQ(frame.external_force[2U], 4.0); EXPECT_DOUBLE_EQ(frame.kinetic_energy, 0.0); EXPECT_DOUBLE_EQ(frame.energy_defect, 0.0); for (std::size_t i = 0; i < 24U; ++i) { EXPECT_DOUBLE_EQ(frame.generalized_coordinates[i], 0.0); EXPECT_DOUBLE_EQ(frame.acceleration[i], 0.0); } } } TEST(HhtDynamicProcedure, UnloadedFreeChannelsRemainExactlyAtRest) { for (const int fixed_first_dof : {0, 1, 4}) { SCOPED_TRACE(fixed_first_dof); auto definition = MakeDefinition(false); if (fixed_first_dof != 0) { definition.dynamic_step->boundaries = {{"All", fixed_first_dof, fixed_first_dof + 2, 0.0, {"dynamic-state.inp", 11U}}}; } auto domain = fesa::Domain::Create(std::move(definition)); ASSERT_TRUE(domain.HasValue()); fesa::SerialParallelFor parallel; fesa::MklPardisoSolver mass; fesa::MklPardisoTangentSolver tangent; fesa::NewtonIterationLogWriter log; const auto path = std::filesystem::temp_directory_path() / "fesa-hht-rest.log"; ASSERT_TRUE(log.Open(path).IsOk()); auto result = fesa::HhtDynamicProcedureExecutor(parallel, mass, tangent) .Execute(domain.Value(), log); ASSERT_TRUE(log.Close().IsOk()); std::filesystem::remove(path); ASSERT_TRUE(result.HasValue()) << (result.HasValue() ? "" : result.GetStatus().Diagnostics().front().message); ASSERT_EQ(result.Value().state.AcceptedFrames().size(), 3U); for (const auto& frame : result.Value().state.AcceptedFrames()) { EXPECT_EQ(frame.time_integration_formulation, fesa::DynamicTimeIntegrationFormulation::kReducedCoordinateHht); EXPECT_EQ(frame.iteration_count, 0U); for (const auto* values : {&frame.generalized_coordinates, &frame.velocity, &frame.acceleration, &frame.algorithmic_acceleration, &frame.reaction, &frame.residual}) { for (std::size_t i = 0; i < values->Size(); ++i) EXPECT_DOUBLE_EQ((*values)[i], 0.0); } EXPECT_DOUBLE_EQ(frame.kinetic_energy, 0.0); EXPECT_DOUBLE_EQ(frame.physical_strain_energy, 0.0); EXPECT_DOUBLE_EQ(frame.drilling_energy, 0.0); EXPECT_DOUBLE_EQ(frame.energy_defect, 0.0); for (const double metric : frame.free_residual_metrics) EXPECT_DOUBLE_EQ(metric, 0.0); } } } TEST(HhtDynamicProcedure, LargeDirectStepStartsFromAcceptedGeometry) { auto definition = MakeDefinition(false); definition.materials[0U].youngs_modulus = 1e9; definition.dynamic_step->increment_count = 1U; definition.dynamic_step->time_period = definition.dynamic_step->initial_increment; definition.node_sets[0U].node_indices = {0U, 3U}; definition.node_sets[1U].node_indices = {2U}; definition.dynamic_step->boundaries = { {"All", 1, 6, 0.0, {"dynamic-state.inp", 11U}}}; definition.dynamic_step->loads = { {"Loaded", 3, -40000.0, {"dynamic-state.inp", 12U}}}; auto domain = fesa::Domain::Create(std::move(definition)); ASSERT_TRUE(domain.HasValue()); fesa::SerialParallelFor parallel; fesa::MklPardisoSolver mass; fesa::MklPardisoTangentSolver tangent; fesa::NewtonIterationLogWriter log; const auto path = std::filesystem::temp_directory_path() / "fesa-hht-large-direct.log"; ASSERT_TRUE(log.Open(path).IsOk()); auto result = fesa::HhtDynamicProcedureExecutor(parallel, mass, tangent) .Execute(domain.Value(), log); ASSERT_TRUE(log.Close().IsOk()); ASSERT_TRUE(result.HasValue()) << (result.HasValue() ? "" : result.GetStatus().Diagnostics().front().message); EXPECT_GT( std::abs(result.Value().state.AcceptedFrames()[0U].acceleration[14U]), 1e5); EXPECT_LT(result.Value().state.CommittedState().generalized_coordinates[14U], 0.0); for (double metric : result.Value().state.CommittedState().free_residual_metrics) EXPECT_LE(metric, 1e-10); EXPECT_GT(result.Value().state.CommittedState().iteration_count, 0U); const auto& frames = result.Value().state.AcceptedFrames(); const double alpha = domain.Value().Dynamic()->alpha; for (std::size_t frame_index = 1U; frame_index < frames.size(); ++frame_index) { const auto& frame = frames[frame_index]; EXPECT_EQ(frame.time_integration_formulation, fesa::DynamicTimeIntegrationFormulation::kReducedCoordinateHht); for (std::size_t dof = 0U; dof < frame.acceleration.Size(); ++dof) { EXPECT_DOUBLE_EQ(frame.algorithmic_acceleration[dof], (1.0 + alpha) * frame.acceleration[dof] - alpha * frames[frame_index - 1U].acceleration[dof]); } } std::filesystem::remove(path); } TEST(HhtDynamicProcedure, PreservesBackendCauseAndAddsInitialFailureContext) { auto definition = MakeDefinition(false); definition.dynamic_step->dynamic_location = {"dynamic-state.inp", 13U}; auto domain = fesa::Domain::Create(std::move(definition)); ASSERT_TRUE(domain.HasValue()); fesa::SerialParallelFor parallel; fesa::MklPardisoSolver mass; FailingTangent tangent; fesa::NewtonIterationLogWriter log; const auto path = std::filesystem::temp_directory_path() / "fesa-hht-context.log"; ASSERT_TRUE(log.Open(path).IsOk()); const auto result = fesa::HhtDynamicProcedureExecutor(parallel, mass, tangent) .Execute(domain.Value(), log); ASSERT_TRUE(log.Close().IsOk()); std::filesystem::remove(path); ASSERT_FALSE(result.HasValue()); ASSERT_FALSE(result.GetStatus().Diagnostics().empty()); EXPECT_EQ(result.GetStatus().Diagnostics().front().code, "injected-tangent-backend"); const auto found = std::find_if( result.GetStatus().Diagnostics().begin(), result.GetStatus().Diagnostics().end(), [](const fesa::Diagnostic& diagnostic) { return diagnostic.code == "dynamic-procedure-context"; }); ASSERT_NE(found, result.GetStatus().Diagnostics().end()); EXPECT_EQ(found->location.file, std::filesystem::path{"dynamic-state.inp"}); EXPECT_EQ(found->location.line, 13U); EXPECT_EQ(found->entity_identity, "Transient Load"); EXPECT_NE(found->message.find("stage=initial"), std::string::npos); EXPECT_NE(found->message.find("increment=0"), std::string::npos); EXPECT_NE(found->message.find("time=0"), std::string::npos); } TEST(HhtDynamicProcedure, UniformTranslationHasNoShellStrainAndIsDeterministicAcrossPartitions) { const auto definition = MakeRigidTranslationDefinition(); const auto rotated_definition = MakeRotatedRigidTranslationDefinition(); const auto finite_rotation_definition = MakeFiniteRotationDefinition(); const auto rotated_finite_rotation_definition = MakeRotatedFiniteRotationDefinition(); const fesa::SerialParallelFor serial; const fesa::TbbParallelFor tbb; auto first = RunRigidTranslation(definition, serial, "serial-first"); auto partitioned = RunRigidTranslation(definition, tbb, "tbb"); auto second = RunRigidTranslation(definition, serial, "serial-second"); auto rotated = RunRigidTranslation(rotated_definition, serial, "rotated"); auto finite_rotation = RunRigidTranslation(finite_rotation_definition, serial, "finite-rotation"); auto rotated_finite_rotation = RunRigidTranslation( rotated_finite_rotation_definition, serial, "rotated-finite-rotation"); ASSERT_TRUE(first.HasValue()) << (first.HasValue() ? "" : FailureText(first.GetStatus())); ASSERT_TRUE(partitioned.HasValue()) << (partitioned.HasValue() ? "" : FailureText(partitioned.GetStatus())); ASSERT_TRUE(second.HasValue()) << (second.HasValue() ? "" : FailureText(second.GetStatus())); ASSERT_TRUE(rotated.HasValue()) << (rotated.HasValue() ? "" : FailureText(rotated.GetStatus())); ASSERT_TRUE(finite_rotation.HasValue()) << (finite_rotation.HasValue() ? "" : FailureText(finite_rotation.GetStatus())); ASSERT_TRUE(rotated_finite_rotation.HasValue()) << (rotated_finite_rotation.HasValue() ? "" : FailureText(rotated_finite_rotation.GetStatus())); ExpectSameDynamicHistory(first.Value(), partitioned.Value()); ExpectSameDynamicHistory(first.Value(), second.Value()); ExpectCovariantDynamicHistory(first.Value(), rotated.Value(), 0.5, 2.0 * 1000.0 * 0.1); ExpectCovariantDynamicHistory(finite_rotation.Value(), rotated_finite_rotation.Value(), 0.02, 1000.0 * 0.1); const auto& frames = first.Value().execution.state.AcceptedFrames(); ASSERT_EQ(frames.size(), 3U); constexpr double kReferenceEnergy = 2.0 * 1000.0 * 0.1; constexpr double kCoordinateScale = 1.0; for (const auto& frame : frames) { EXPECT_LE(std::abs(frame.physical_strain_energy) / kReferenceEnergy, 1e-10); EXPECT_LE(std::abs(frame.drilling_energy) / kReferenceEnergy, 1e-10); for (std::size_t node = 1U; node < 8U; ++node) EXPECT_LE(std::abs(frame.generalized_coordinates[6U * node] - frame.generalized_coordinates[0U]) / kCoordinateScale, 1e-12); for (std::size_t node = 0U; node < 8U; ++node) { EXPECT_LE(std::abs(frame.generalized_coordinates[6U * node + 1U]) / kCoordinateScale, 1e-12); EXPECT_LE(std::abs(frame.generalized_coordinates[6U * node + 2U]) / kCoordinateScale, 1e-12); for (std::size_t rotation = 3U; rotation < 6U; ++rotation) EXPECT_LE(std::abs(frame.generalized_coordinates[6U * node + rotation]), 1e-12); } } EXPECT_GT(frames.back().generalized_coordinates[0U], 0.0); double maximum_rotation = 0.0; for (const auto& frame : finite_rotation.Value().execution.state.AcceptedFrames()) for (std::size_t node = 0U; node < 4U; ++node) for (std::size_t component = 3U; component < 6U; ++component) maximum_rotation = (std::max)( maximum_rotation, std::abs(frame.generalized_coordinates[6U * node + component])); EXPECT_GT(maximum_rotation, 0.0); } } // namespace namespace { std::map LogFields(const std::string& line) { std::map fields; std::istringstream input(line); std::string token; while (std::getline(input, token, '\t')) { const auto equal = token.find('='); if (equal != std::string::npos) fields.emplace(token.substr(0, equal), token.substr(equal + 1)); } return fields; } } namespace { fesa::ModelDefinition SymmetricExtension() { auto definition = MakeDefinition(false); definition.dynamic_step->initial_increment = 0.1; definition.dynamic_step->time_period = 0.1; definition.dynamic_step->increment_count = 1U; definition.node_sets.push_back({"Left", std::nullopt, {0U, 3U}, {"dynamic-state.inp", 30U}}); definition.node_sets.push_back({"Right", std::nullopt, {1U, 2U}, {"dynamic-state.inp", 31U}}); definition.dynamic_step->boundaries = { {"All", 2, 6, 0.0, {"dynamic-state.inp", 32U}}}; definition.dynamic_step->loads = { {"Left", 1, -1.0, {"dynamic-state.inp", 33U}}, {"Right", 1, 1.0, {"dynamic-state.inp", 34U}}}; return definition; } } TEST(HhtDynamicProcedure, FieldPolicyActuallyTerminatesSymmetricExtensionBeforeStrict) { auto definition = SymmetricExtension(); fesa::SerialParallelFor parallel; auto run = RunRigidTranslation(definition, parallel, "field-policy-extension"); ASSERT_TRUE(run.HasValue()) << (run.HasValue() ? "" : FailureText(run.GetStatus())); const auto& frame = run.Value().execution.state.CommittedState(); ASSERT_GT(frame.iteration_count, 0U); ASSERT_LE(frame.iteration_count, 100U); const auto& text = run.Value().log; const auto accepted = text.rfind("record=dynamic-newton-iteration"); ASSERT_NE(accepted, std::string::npos); const auto line = text.substr(accepted); EXPECT_NE(line.find("\tschema=hht-log-v2\t"), std::string::npos); EXPECT_NE(line.find("\tpolicy_id=abaqus2025-field-on-fesa-v1\t"), std::string::npos); EXPECT_NE(line.find("\tstep=Transient Load\t"), std::string::npos); EXPECT_NE(line.find("\tfield_pass=1\taccepted=1\t"), std::string::npos); EXPECT_NE(line.find("\tstrict_correction_pass=0"), std::string::npos); for (const auto value : frame.global_equilibrium) EXPECT_LE(std::abs(value), 1e-8); auto fields = LogFields(line.substr(0, line.find('\n'))); double rho = 0, increment = 0, flux = 4.0; for (std::size_t node = 0; node < 4; ++node) { rho = (std::max)(rho, std::abs(frame.residual[6 * node])); increment = (std::max)(increment, std::abs(frame.generalized_coordinates[6 * node])); for (std::size_t c = 0; c < 3; ++c) flux += std::abs(frame.internal_force[6 * node + c] + frame.inertia_force[6 * node + c]); } EXPECT_DOUBLE_EQ(std::stod(fields.at("force_residual_numerator")), rho); EXPECT_DOUBLE_EQ(std::stod(fields.at("force_increment_norm")), increment); EXPECT_DOUBLE_EQ(std::stod(fields.at("force_spatial_flux")), flux / 24.0); EXPECT_DOUBLE_EQ(std::stod(fields.at("force_time_average_flux")), flux / 24.0); EXPECT_LE(rho, .005 * std::stod(fields.at("force_residual_scale"))); EXPECT_DOUBLE_EQ(std::stod(fields.at("force_residual_threshold")), .005); EXPECT_DOUBLE_EQ(std::stod(fields.at("force_correction_threshold")), .01); EXPECT_EQ(fields.at("force_residual_pass"), "1"); EXPECT_EQ(fields.at("force_correction_pass"), "1"); const double c = std::stod(fields.at("force_correction_numerator")); const auto& estimated = fields.at("force_estimated_correction"); EXPECT_TRUE(c <= .01 * increment || (estimated != "not-evaluated" && std::stod(estimated) <= .01 * increment)); EXPECT_EQ(fields.at("force_branch"), "normal"); EXPECT_EQ(fields.at("moment_branch"), "not-evaluated"); // Symmetric monotone extension has q=(-D,+D,+D,-D). This independent // endpoint oracle includes the selected iteration-zero start, not the lift. std::istringstream records(text); std::string row; double previous_D = 0; bool first = true; while (std::getline(records, row)) { const auto values = LogFields(row); if (values.at("schema") != "hht-log-v2") continue; const double D = std::stod(values.at("force_increment_norm")); if (first) { EXPECT_EQ(values.at("force_correction_numerator"), "not-evaluated"); EXPECT_EQ(values.at("force_estimated_correction"), "not-evaluated"); EXPECT_EQ(values.at("field_pass"), "0"); } else { EXPECT_NEAR(std::stod(values.at("force_correction_numerator")), std::abs(D - previous_D), 1e-16); } previous_D = D; first = false; } } TEST(HhtDynamicProcedure, FieldPassDoesNotBypassPhysicalAcceptanceAndLogsActualCause) { auto definition = MakeDefinition(false); definition.materials[0].youngs_modulus = 1e9; definition.dynamic_step->increment_count = 1; definition.dynamic_step->time_period = definition.dynamic_step->initial_increment; definition.node_sets[0].node_indices = {0U, 3U}; definition.node_sets[1].node_indices = {2U}; definition.dynamic_step->boundaries = {{"All", 1, 6, 0, {"dynamic-state.inp", 11U}}}; definition.dynamic_step->loads = {{"Loaded", 3, -40000, {"dynamic-state.inp", 12U}}}; auto domain = fesa::Domain::Create(definition); ASSERT_TRUE(domain.HasValue()); fesa::SerialParallelFor parallel; fesa::MklPardisoSolver mass; fesa::MklPardisoTangentSolver tangent; fesa::NewtonIterationLogWriter log; const auto path = std::filesystem::temp_directory_path() / "fesa-field-post-gate.log"; ASSERT_TRUE(log.Open(path).IsOk()); auto result = fesa::HhtDynamicProcedureExecutor(parallel, mass, tangent).Execute(domain.Value(), log); ASSERT_TRUE(log.Close().IsOk()); std::ifstream input(path, std::ios::binary); const std::string text{std::istreambuf_iterator{input}, {}}; input.close(); std::filesystem::remove(path); ASSERT_FALSE(result.HasValue()); const auto row = text.substr(text.rfind("record=")); const auto fields = LogFields(row.substr(0, row.find('\n'))); EXPECT_EQ(fields.at("field_pass"), "1"); EXPECT_EQ(fields.at("accepted"), "0"); EXPECT_EQ(fields.at("outcome"), "failed"); const auto cause = std::find_if(result.GetStatus().Diagnostics().begin(), result.GetStatus().Diagnostics().end(), [](const auto& d) { return d.code != "dynamic-procedure-context"; }); ASSERT_NE(cause, result.GetStatus().Diagnostics().end()); EXPECT_EQ(fields.at("failure_code"), cause->code) << FailureText(result.GetStatus()); EXPECT_EQ(text.find("\taccepted=1"), std::string::npos); } TEST(HhtDynamicProcedure, AcceptedFluxHistoryCountsEachIncrementExactlyOnce) { auto definition = SymmetricExtension(); definition.dynamic_step->time_period = .2; definition.dynamic_step->increment_count = 2; fesa::SerialParallelFor parallel; auto run = RunRigidTranslation(definition, parallel, "field-history"); ASSERT_TRUE(run.HasValue()) << (run.HasValue() ? "" : FailureText(run.GetStatus())); const auto& frames = run.Value().execution.state.AcceptedFrames(); ASSERT_EQ(frames.size(), 3U); double sum = 0, maximum_sum = 0; std::size_t n = 0; std::istringstream input(run.Value().log); std::string row; while (std::getline(input, row)) { const auto fields = LogFields(row); if (fields.at("schema") != "hht-log-v2" || fields.at("accepted") != "1") continue; ++n; const auto& frame = frames[n]; std::vector flux; for (std::size_t node = 0; node < 4; ++node) for (std::size_t c = 0; c < 3; ++c) flux.push_back(std::abs(frame.internal_force[6 * node + c] + frame.inertia_force[6 * node + c])); for (int node = 0; node < 4; ++node) { flux.push_back(1); flux.push_back(0); flux.push_back(0); } const double m = *std::max_element(flux.begin(), flux.end()); const double H = n == 1 ? 0 : maximum_sum / static_cast(n - 1); double selected_sum = 0; std::size_t count = 0; for (double x : flux) if (m < .1 * H || x >= 1e-5 * H) { selected_sum += x; ++count; } ASSERT_GT(count, 0U); const double spatial = selected_sum / static_cast(count); sum += spatial; maximum_sum += m; EXPECT_DOUBLE_EQ(std::stod(fields.at("force_spatial_flux")), spatial); EXPECT_DOUBLE_EQ(std::stod(fields.at("force_time_average_flux")), sum / static_cast(n)); EXPECT_EQ(std::stoul(fields.at("iteration")), frame.iteration_count); } EXPECT_EQ(n, 2U); } TEST(HhtDynamicProcedure, CandidateRigidTranslationMatchesIndependentPhysicalOracle) { // Two disconnected unit squares: rho=2, thickness=.1, area=1 each. // Each nodal lump has mass .05 and receives F_x=1, hence a_x=20. // This exact rigid motion verifies endpoint semantics, not temporal order. constexpr double kNumericalTolerance = 1e-3; constexpr double kAcceleration = 20.0; constexpr double kTotalMass = 0.4; constexpr double kTotalForce = 8.0; constexpr double kLength = 1.0; constexpr double kTime = 0.5; constexpr double kEnergy = 20.0; fesa::SerialParallelFor parallel; for (const bool constrain_transverse : {false, true}) { SCOPED_TRACE(constrain_transverse); auto definition = MakeRigidTranslationDefinition(); if (constrain_transverse) { definition.dynamic_step->boundaries = { {"All-A", 2, 6, 0.0, {"dynamic-state.inp", 30U}}, {"All-B", 2, 6, 0.0, {"dynamic-state.inp", 31U}}}; } auto run = RunRigidTranslation(definition, parallel, constrain_transverse ? "analytic-bc" : "analytic-free"); ASSERT_TRUE(run.HasValue()) << (run.HasValue() ? "" : FailureText(run.GetStatus())); const auto& frames = run.Value().execution.state.AcceptedFrames(); ASSERT_EQ(frames.size(), 3U); for (std::size_t index = 0U; index < frames.size(); ++index) { const auto& frame = frames[index]; EXPECT_DOUBLE_EQ(frame.time, static_cast(index) * 0.25); const double q = 0.5 * kAcceleration * frame.time * frame.time; const double v = kAcceleration * frame.time; for (std::size_t node = 0U; node < 8U; ++node) { const auto dof = 6U * node; EXPECT_LE(std::abs(frame.generalized_coordinates[dof] - q) / kLength, kNumericalTolerance); EXPECT_LE(std::abs(frame.velocity[dof] - v) * kTime / kLength, kNumericalTolerance); EXPECT_LE(std::abs(frame.acceleration[dof] - kAcceleration) * kTime * kTime / kLength, kNumericalTolerance); EXPECT_LE(std::abs(frame.algorithmic_acceleration[dof] - kAcceleration) * kTime * kTime / kLength, kNumericalTolerance); EXPECT_DOUBLE_EQ(frame.external_force[dof], 1.0); EXPECT_LE(std::abs(frame.inertia_force[dof] - 1.0), kNumericalTolerance); for (std::size_t component = 1U; component < 6U; ++component) { const double scale = component < 3U ? kLength : 1.0; EXPECT_LE(std::abs(frame.generalized_coordinates[dof + component]) / scale, kNumericalTolerance); EXPECT_LE(std::abs(frame.velocity[dof + component]) * kTime / scale, kNumericalTolerance); EXPECT_LE(std::abs(frame.acceleration[dof + component]) * kTime * kTime / scale, kNumericalTolerance); EXPECT_LE(std::abs(frame.algorithmic_acceleration[dof + component]) * kTime * kTime / scale, kNumericalTolerance); if (constrain_transverse) { EXPECT_DOUBLE_EQ(frame.generalized_coordinates[dof + component], 0.0); EXPECT_DOUBLE_EQ(frame.velocity[dof + component], 0.0); EXPECT_DOUBLE_EQ(frame.acceleration[dof + component], 0.0); EXPECT_DOUBLE_EQ(frame.algorithmic_acceleration[dof + component], 0.0); } } } EXPECT_LE(std::abs(frame.kinetic_energy - 0.5 * kTotalMass * v * v) / kEnergy, kNumericalTolerance); EXPECT_LE(std::abs(frame.external_work - kTotalForce * q) / kEnergy, kNumericalTolerance); EXPECT_LE(std::abs(frame.physical_strain_energy) / kEnergy, kNumericalTolerance); EXPECT_LE(std::abs(frame.drilling_energy) / kEnergy, kNumericalTolerance); EXPECT_LE(std::abs(frame.energy_defect) / kEnergy, kNumericalTolerance); for (const auto value : frame.global_equilibrium) EXPECT_LE(std::abs(value), 1e-8); } std::size_t accepted = 0U; std::istringstream input(run.Value().log); std::string row; while (std::getline(input, row)) { const auto fields = LogFields(row); if (fields.at("schema") != "hht-log-v2") continue; EXPECT_EQ(fields.at("policy_id"), "abaqus2025-field-on-fesa-v1"); if (fields.at("accepted") == "1") { EXPECT_EQ(fields.at("field_pass"), "1"); ++accepted; } } EXPECT_EQ(accepted, 2U); } } TEST(HhtDynamicProcedure, LoadFluxPreservesSourceCancellationAndExplicitZeroEntries) { auto definition = MakeDefinition(true); definition.dynamic_step->loads = { {"Loaded", 1, 3, {"dynamic-state.inp", 30U}}, {"Loaded", 1, -3, {"dynamic-state.inp", 31U}}, {"Loaded", 2, 0, {"dynamic-state.inp", 32U}}}; auto domain = fesa::Domain::Create(definition); ASSERT_TRUE(domain.HasValue()); auto model = fesa::AnalysisModel::Create(domain.Value()); ASSERT_TRUE(model.HasValue()); auto dofs = fesa::DofManager::Create(model.Value()); ASSERT_TRUE(dofs.HasValue()); std::array, 2> flux; const auto loads = fesa::hht_internal::AssembleLoads(domain.Value(), dofs.Value(), &flux); ASSERT_TRUE(loads.HasValue()); EXPECT_DOUBLE_EQ(loads.Value()[0][0], 0); EXPECT_DOUBLE_EQ(loads.Value()[1][0], 6); ASSERT_EQ(flux[0].size(), 9U); ASSERT_EQ(flux[1].size(), 9U); const std::array expected{3, 0, 0, -3, 0, 0, 0, 0, 0}; for (std::size_t i = 0; i < expected.size(); ++i) { EXPECT_DOUBLE_EQ(flux[0][i].internal, expected[i]); EXPECT_DOUBLE_EQ(flux[0][i].inertia, 0); EXPECT_DOUBLE_EQ(flux[1][i].internal, 0); } } namespace { // All-fixed fixture has no private coordinates. This adapter exercises the // real production acceptance/output transaction without a test solver path. class EmptyCoordinateIntegrator final : public fesa::hht_reduced_internal::ShellHhtIntegrator { public: fesa::hht_reduced_internal::ReducedShellPredictor Predict( const fesa::hht_reduced_internal::ReducedHistory& history, double, const fesa::HhtParameters&) const override { EXPECT_EQ(history.p.Size(), 0U); return {}; } void EvaluateRates(const fesa::hht_reduced_internal::ReducedShellRateRequest&, fesa::Vector& v, fesa::Vector& a) const override { EXPECT_EQ(v.Size(), 0U); EXPECT_EQ(a.Size(), 0U); } fesa::Result ReconstructPhysicalRates( const fesa::hht_reduced_internal::FixedChart& chart, const fesa::hht_reduced_internal::EmbeddingRoot& root, const fesa::Vector& v, const fesa::Vector& a, fesa::TangentSolver& solver) const override { return fesa::hht_reduced_internal::ReconstructPhysicalRates(chart, root, v, a, solver); } }; class CloseAtCandidate final : public fesa::EquilibriumAlgorithm { public: explicit CloseAtCandidate(fesa::NewtonIterationLogWriter& log) : log_(log) {} fesa::Status SolveCurrentStep(std::uint32_t first, std::uint32_t last, const Evaluation& evaluate) override { EXPECT_EQ(first, 0U); EXPECT_EQ(last, 100U); auto value = evaluate(first); if (!value.HasValue()) return value.GetStatus(); EXPECT_TRUE(value.Value()); return log_.Close(); } private: fesa::NewtonIterationLogWriter& log_; }; } TEST(HhtDynamicProcedure, FailedAcceptanceOutputPublishesNoCandidateAndFreshRetryIsUnchanged) { auto domain = fesa::Domain::Create(MakeDefinition()); ASSERT_TRUE(domain.HasValue()); fesa::SerialParallelFor parallel; fesa::MklPardisoSolver mass; fesa::MklPardisoTangentSolver tangent; fesa::NewtonIterationLogWriter log; const auto path = std::filesystem::temp_directory_path() / "fesa-field-output-fail.log"; ASSERT_TRUE(log.Open(path).IsOk()); EmptyCoordinateIntegrator integrator; CloseAtCandidate algorithm(log); const auto result = fesa::ExecuteReducedHht(domain.Value(), log, parallel, mass, tangent, integrator, algorithm); ASSERT_FALSE(result.HasValue()); EXPECT_EQ(result.GetStatus().Category(), fesa::FailureCategory::kOutput); std::ifstream input(path, std::ios::binary); const std::string text{std::istreambuf_iterator{input}, {}}; input.close(); std::filesystem::remove(path); EXPECT_EQ(text.find("accepted=1"), std::string::npos); auto first = RunRigidTranslation(MakeDefinition(), parallel, "field-retry"); auto second = RunRigidTranslation(MakeDefinition(), parallel, "field-retry-repeat"); ASSERT_TRUE(first.HasValue()); ASSERT_TRUE(second.HasValue()); ExpectSameDynamicHistory(first.Value(), second.Value()); } namespace { class FailNextSelectedTrial final : public fesa::TangentSolver { public: fesa::Status FactorizeFresh(const fesa::SparseMatrix& matrix) override { if (outer_solved_) return fesa::Status::Failure(fesa::FailureCategory::kSolver, {{fesa::Severity::kError, "injected-next-trial", {}, "DYNAMIC", {}, "Fail before the next selected field evaluation."}}); large_ = matrix.Rows() > 4; return solver_.FactorizeFresh(matrix); } fesa::Status SolveCorrection(const fesa::Vector& rhs, fesa::Vector& solution) const override { auto status = solver_.SolveCorrection(rhs, solution); if (status.IsOk() && large_) outer_solved_ = true; return status; } private: fesa::MklPardisoTangentSolver solver_; bool large_ = false; mutable bool outer_solved_ = false; }; } TEST(HhtDynamicProcedure, FailureBeforeNextEvaluationDoesNotReusePreviousFieldEvidence) { auto domain = fesa::Domain::Create(MakeFiniteRotationDefinition()); ASSERT_TRUE(domain.HasValue()); fesa::SerialParallelFor parallel; fesa::MklPardisoSolver mass; FailNextSelectedTrial tangent; fesa::NewtonIterationLogWriter log; const auto path = std::filesystem::temp_directory_path() / "fesa-field-next-fail.log"; ASSERT_TRUE(log.Open(path).IsOk()); const auto result = fesa::HhtDynamicProcedureExecutor(parallel, mass, tangent).Execute(domain.Value(), log); ASSERT_TRUE(log.Close().IsOk()); std::ifstream input(path, std::ios::binary); const std::string text{std::istreambuf_iterator{input}, {}}; input.close(); std::filesystem::remove(path); ASSERT_FALSE(result.HasValue()); const auto row = text.substr(text.rfind("record=")); const auto fields = LogFields(row.substr(0, row.find('\n'))); EXPECT_EQ(fields.at("failure_code"), "injected-next-trial") << FailureText(result.GetStatus()); EXPECT_EQ(fields.at("iteration"), "1"); EXPECT_EQ(fields.at("force_residual_numerator"), "not-evaluated"); EXPECT_EQ(fields.at("moment_residual_scale"), "not-evaluated"); EXPECT_EQ(fields.at("field_pass"), "not-evaluated"); EXPECT_EQ(fields.at("accepted"), "0"); } namespace { class EvaluateEndpoint100 final : public fesa::EquilibriumAlgorithm { public: fesa::Status SolveCurrentStep(std::uint32_t first, std::uint32_t last, const Evaluation& evaluate) override { EXPECT_EQ(first, 0U); EXPECT_EQ(last, 100U); auto value = evaluate(last); if (!value.HasValue()) return value.GetStatus(); EXPECT_TRUE(value.Value()); return fesa::Status::Ok(); } }; } TEST(HhtDynamicProcedure, FieldAcceptanceEvaluatesProductionEndpoint100BeforeCorrectionBudget) { auto domain = fesa::Domain::Create(MakeDefinition()); ASSERT_TRUE(domain.HasValue()); fesa::SerialParallelFor parallel; fesa::MklPardisoSolver mass; fesa::MklPardisoTangentSolver tangent; fesa::NewtonIterationLogWriter log; const auto path = std::filesystem::temp_directory_path() / "fesa-field-endpoint100.log"; ASSERT_TRUE(log.Open(path).IsOk()); EmptyCoordinateIntegrator integrator; EvaluateEndpoint100 algorithm; // Tests the real endpoint callback at the inclusive boundary. No physical // trajectory is forced through 100 corrections; all fixed coordinates stay zero. const auto result = fesa::ExecuteReducedHht(domain.Value(), log, parallel, mass, tangent, integrator, algorithm); ASSERT_TRUE(log.Close().IsOk()); std::ifstream input(path, std::ios::binary); const std::string text{std::istreambuf_iterator{input}, {}}; input.close(); std::filesystem::remove(path); ASSERT_TRUE(result.HasValue()) << (result.HasValue() ? "" : FailureText(result.GetStatus())); ASSERT_EQ(result.Value().state.AcceptedFrames().size(), 3U); EXPECT_EQ(result.Value().state.AcceptedFrames()[1].iteration_count, 100U); EXPECT_EQ(result.Value().state.AcceptedFrames()[2].iteration_count, 100U); const auto row = text.substr(text.rfind("record=")); const auto fields = LogFields(row.substr(0, row.find('\n'))); EXPECT_EQ(fields.at("iteration"), "100"); EXPECT_EQ(fields.at("field_pass"), "1"); EXPECT_EQ(fields.at("accepted"), "1"); }