From e0a6a6fa70354a90d0f8178c04cd8845e3ea1b27 Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Sun, 2 Aug 2026 01:28:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(result-contract-completion):=20step=201=20?= =?UTF-8?q?=E2=80=94=20complete-result-contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- include/fesa/results/result_database.hpp | 39 +++++ src/fesa/analysis/linear_static_analysis.cpp | 89 ++++++++++ src/fesa/io/hdf5/writer.cpp | 16 +- src/fesa/results/result_database.cpp | 86 ++++++++++ tests/CMakeLists.txt | 14 +- tests/integration/io/hdf5_results_test.cpp | 13 ++ .../analysis/linear_static_analysis_test.cpp | 47 +++++- tests/unit/results/result_database_test.cpp | 155 +++++++++++++++++- 8 files changed, 454 insertions(+), 5 deletions(-) diff --git a/include/fesa/results/result_database.hpp b/include/fesa/results/result_database.hpp index 818db88..a8c6a1e 100644 --- a/include/fesa/results/result_database.hpp +++ b/include/fesa/results/result_database.hpp @@ -2,23 +2,62 @@ #include #include +#include #include #include #include +#include +#include #include namespace fesa { +enum class FieldCoordinateSystem { global, element_local }; + struct NodalFrame final { + inline static constexpr FieldCoordinateSystem coordinate_system = + FieldCoordinateSystem::global; + inline static constexpr std::array + displacement_components{ + "Ux", "Uy", "Uz", "Rx", "Ry", "Rz"}; + inline static constexpr std::array + reaction_components{ + "RFx", "RFy", "RFz", "RMx", "RMy", "RMz"}; + std::vector node_ids; + std::vector origins; std::vector> displacement; std::vector> reaction; }; +struct BeamElementFrame final { + inline static constexpr FieldCoordinateSystem coordinate_system = + FieldCoordinateSystem::element_local; + inline static constexpr std::array + section_strain_components{ + "epsilon", "gamma_y", "gamma_z", "kappa_x", "kappa_y", + "kappa_z"}; + inline static constexpr std::array + section_force_components{ + "N", "Vy", "Vz", "T", "My", "Mz"}; + inline static constexpr std::string_view axial_stress_component = + "sigma_xx"; + + ElementId element; + EntityOrigin origin; + BeamFrame local_frame; + std::array end_results; +}; + +struct ElementFrame final { + std::vector beams; +}; + struct ResultFrame final { double step_time; NodalFrame nodal; + ElementFrame element; std::vector diagnostics; }; diff --git a/src/fesa/analysis/linear_static_analysis.cpp b/src/fesa/analysis/linear_static_analysis.cpp index 3ec5571..6aa0e0a 100644 --- a/src/fesa/analysis/linear_static_analysis.cpp +++ b/src/fesa/analysis/linear_static_analysis.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -12,6 +13,7 @@ #include #include #include +#include #include #include @@ -34,6 +36,18 @@ AnalysisRunResult equation_failure( }}); } +AnalysisRunResult results_failure( + std::string code, + std::string message) { + return failure({{ + DiagnosticStage::results, + Severity::error, + std::move(code), + std::move(message), + std::nullopt, + }}); +} + NodalFrame build_nodal_frame( const Domain& domain, const DofManager& dofs, @@ -48,9 +62,11 @@ NodalFrame build_nodal_frame( NodalFrame nodal; nodal.node_ids = std::move(node_ids); + nodal.origins.reserve(nodal.node_ids.size()); nodal.displacement.reserve(nodal.node_ids.size()); nodal.reaction.reserve(nodal.node_ids.size()); for (const NodeId node_id : nodal.node_ids) { + nodal.origins.push_back(domain.node(node_id).origin); std::array node_displacement{}; std::array node_reaction{}; for (std::size_t component = 0; component < 6; ++component) { @@ -67,6 +83,70 @@ NodalFrame build_nodal_frame( return nodal; } +ElementFrame build_element_frame( + const Domain& domain, + const DofManager& dofs, + const std::vector& displacement) { + std::vector elements; + elements.reserve(domain.beam_elements().size()); + for (const BeamElement& element : domain.beam_elements()) { + elements.push_back(&element); + } + std::ranges::sort( + elements, + {}, + [](const BeamElement* element) { + return element->id.value(); + }); + + ElementFrame frame; + frame.beams.reserve(elements.size()); + for (const BeamElement* element : elements) { + const Beam3D2Input input{ + { + domain.node(element->nodes[0]).position, + domain.node(element->nodes[1]).position, + }, + element->nodes, + domain.material(element->material), + domain.section(element->section), + }; + const BeamKernelResult kernel = compute_beam3d2(input); + if (!kernel.contribution.has_value()) { + const std::string message = kernel.diagnostics.empty() + ? "Beam recovery requires a valid element input." + : kernel.diagnostics.front().message; + throw std::runtime_error{message}; + } + + std::array element_displacement{}; + const std::array full_dofs = + dofs.element_full_dofs(*element); + for (std::size_t local = 0; local < full_dofs.size(); ++local) { + element_displacement[local] = displacement[full_dofs[local]]; + } + std::vector recovered = recover_beam3d2( + input, + element_displacement, + input.section.recovery_points); + if (recovered.size() != 2U) { + throw std::logic_error{ + "Beam recovery must return exactly two end results."}; + } + + frame.beams.push_back({ + element->id, + element->origin, + kernel.contribution->frame, + { + std::move(recovered[0]), + std::move(recovered[1]), + }, + }); + } + return frame; +} + } // namespace AnalysisRunResult LinearStaticAnalysis::run(const Domain& domain) const { @@ -114,6 +194,14 @@ AnalysisRunResult LinearStaticAnalysis::run(const Domain& domain) const { "equation.reaction_recovery_failed", error.what()); } + ElementFrame element; + try { + element = build_element_frame(domain, dofs, displacement); + } catch (const std::exception& error) { + return results_failure( + "results.element_recovery_failed", error.what()); + } + ResultDatabase database{ "1.0.0", {{ @@ -122,6 +210,7 @@ AnalysisRunResult LinearStaticAnalysis::run(const Domain& domain) const { 1.0, build_nodal_frame( domain, dofs, displacement, reaction), + std::move(element), {}, }}, }}, diff --git a/src/fesa/io/hdf5/writer.cpp b/src/fesa/io/hdf5/writer.cpp index bbeb485..783ccee 100644 --- a/src/fesa/io/hdf5/writer.cpp +++ b/src/fesa/io/hdf5/writer.cpp @@ -1364,7 +1364,13 @@ Hdf5ModelSnapshot read_model( ResultDatabase read_database( Hdf5Context& context, const hid_t file, - const std::string& version) { + const std::string& version, + const Hdf5ModelSnapshot& model) { + std::unordered_map node_origins; + for (const Hdf5NodeSnapshot& node : model.nodes) { + node_origins.emplace(node.id.value(), node.origin); + } + ResultDatabase database{version, {}}; auto results_group = open_group(context, file, "results"); auto steps_group = open_group(context, results_group.get(), "steps"); @@ -1419,10 +1425,16 @@ ResultDatabase read_database( "Nodal result datasets have inconsistent row counts."); } frame.nodal.node_ids.reserve(node_ids.size()); + frame.nodal.origins.reserve(node_ids.size()); frame.nodal.displacement.reserve(node_ids.size()); frame.nodal.reaction.reserve(node_ids.size()); for (std::size_t index = 0; index < node_ids.size(); ++index) { frame.nodal.node_ids.emplace_back(node_ids[index]); + const auto origin = node_origins.find(node_ids[index]); + frame.nodal.origins.push_back( + origin == node_origins.end() + ? EntityOrigin{} + : origin->second); std::array displacement_values{}; std::array reaction_values{}; std::copy_n( @@ -1538,7 +1550,7 @@ Hdf5ReadResult read_hdf5_results(const std::filesystem::path& path) { } Hdf5ModelSnapshot model = read_model(context, file.get()); ResultDatabase database = - read_database(context, file.get(), version); + read_database(context, file.get(), version, model); const Status validation = validate_result_database(database); if (!validation.succeeded) { file.reset(); diff --git a/src/fesa/results/result_database.cpp b/src/fesa/results/result_database.cpp index 4c71090..9731386 100644 --- a/src/fesa/results/result_database.cpp +++ b/src/fesa/results/result_database.cpp @@ -43,6 +43,12 @@ void validate_nodal_frame( "Nodal IDs, displacement, and reaction fields must have " "matching sizes."); } + if (nodal.origins.size() != nodal.node_ids.size()) { + add_error( + diagnostics, + "results.nodal_origin_size_mismatch", + "Nodal IDs and origins must have matching sizes."); + } std::set node_ids; for (const NodeId node_id : nodal.node_ids) { @@ -74,6 +80,84 @@ void validate_nodal_frame( } } +void validate_beam_section_result( + const BeamSectionResult& result, + std::vector& diagnostics) { + if ( + !std::isfinite(result.xi) || + !is_finite(result.section_strain) || + !is_finite(result.section_force) || + !std::isfinite(result.centroid_sigma_xx) || + !std::ranges::all_of( + result.sigma_xx, + [](const double value) { return std::isfinite(value); })) { + add_error( + diagnostics, + "results.nonfinite_value", + "Beam section result contains a nonfinite value."); + } +} + +void validate_element_frame( + const ElementFrame& element, + const NodalFrame& nodal, + std::vector& diagnostics) { + std::set nodal_ids; + for (const NodeId node_id : nodal.node_ids) { + nodal_ids.insert(node_id.value()); + } + + std::set element_ids; + for (const BeamElementFrame& beam : element.beams) { + if (!element_ids.insert(beam.element.value()).second) { + add_error( + diagnostics, + "results.duplicate_element_id", + "Element frame contains duplicate element ID " + + std::to_string(beam.element.value()) + "."); + } + + if ( + !is_finite(beam.local_frame.ex) || + !is_finite(beam.local_frame.ey) || + !is_finite(beam.local_frame.ez)) { + add_error( + diagnostics, + "results.nonfinite_value", + "Beam local frame contains a nonfinite component."); + } + + const BeamSectionResult& first = beam.end_results[0]; + const BeamSectionResult& second = beam.end_results[1]; + if (first.end_node == second.end_node) { + add_error( + diagnostics, + "results.duplicate_beam_end_node", + "Beam element result contains the same node at both ends."); + } + if ( + first.xi != -1.0 || second.xi != 1.0 || + !nodal_ids.contains(first.end_node.value()) || + !nodal_ids.contains(second.end_node.value())) { + add_error( + diagnostics, + "results.invalid_beam_connectivity", + "Beam end results must follow (-1, +1) connectivity and " + "reference nodes in the nodal frame."); + } + if (first.sigma_xx.size() != second.sigma_xx.size()) { + add_error( + diagnostics, + "results.recovery_point_count_mismatch", + "Beam end results must have matching recovery-point " + "counts."); + } + + validate_beam_section_result(first, diagnostics); + validate_beam_section_result(second, diagnostics); + } +} + } // namespace Status validate_result_database(const ResultDatabase& database) { @@ -106,6 +190,8 @@ Status validate_result_database(const ResultDatabase& database) { } validate_nodal_frame(frame.nodal, diagnostics); + validate_element_frame( + frame.element, frame.nodal, diagnostics); } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 548c040..0002328 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -598,6 +598,12 @@ add_test( --gtest_filter=ResultDatabase.* ) +add_test( + NAME ElementFrame + COMMAND "$" + --gtest_filter=ElementFrame.* +) + add_executable(fesa_hdf5_results_tests integration/io/hdf5_results_test.cpp ) @@ -674,8 +680,14 @@ add_test( --gtest_filter=StaticEquilibrium.* ) +add_test( + NAME CompleteResultContract + COMMAND "$" + --gtest_filter=CompleteResultContract.* +) + set_property( - TEST LinearStaticAnalysis StaticEquilibrium + TEST LinearStaticAnalysis StaticEquilibrium CompleteResultContract PROPERTY ENVIRONMENT_MODIFICATION ${FESA_DEPENDENCY_RUNTIME_MODIFICATIONS} ) diff --git a/tests/integration/io/hdf5_results_test.cpp b/tests/integration/io/hdf5_results_test.cpp index 2d4221c..77347be 100644 --- a/tests/integration/io/hdf5_results_test.cpp +++ b/tests/integration/io/hdf5_results_test.cpp @@ -226,6 +226,12 @@ fesa::ResultDatabase make_database() { 1.25, { {fesa::NodeId{7}, fesa::NodeId{42}}, + { + fesa::EntityOrigin{ + "BeamPart", "Beam-1", 1002}, + fesa::EntityOrigin{ + "BeamPart", "Beam-1", 1001}, + }, { {1.0, 2.0, 3.0, 4.0, 5.0, 6.0}, {-1.0, -2.0, -3.0, -4.0, -5.0, -6.0}, @@ -236,6 +242,7 @@ fesa::ResultDatabase make_database() { }, }, {}, + {}, }}, }}, }; @@ -279,6 +286,12 @@ TEST(ResultRoundTrip, PreservesMinimalSchemaModelAndNodalResults) { EXPECT_EQ( frame.nodal.node_ids, (std::vector{fesa::NodeId{7}, fesa::NodeId{42}})); + EXPECT_EQ( + frame.nodal.origins, + (std::vector{ + {"BeamPart", "Beam-1", 1002}, + {"BeamPart", "Beam-1", 1001}, + })); EXPECT_EQ( frame.nodal.displacement[0], (std::array{1.0, 2.0, 3.0, 4.0, 5.0, 6.0})); diff --git a/tests/unit/analysis/linear_static_analysis_test.cpp b/tests/unit/analysis/linear_static_analysis_test.cpp index 9ccc970..9746c7c 100644 --- a/tests/unit/analysis/linear_static_analysis_test.cpp +++ b/tests/unit/analysis/linear_static_analysis_test.cpp @@ -59,7 +59,7 @@ fesa::Domain build_axial_domain(const bool all_constrained) { 2.0, fesa::ShearPropertySource::input, fesa::Vec3{0.0, 1.0, 0.0}, - {}, + {{1.0, 2.0}, {-1.0, -2.0}}, }); builder.add_beam_element({ fesa::ElementId{0}, @@ -214,4 +214,49 @@ TEST(LinearStaticAnalysis, SolvesAllConstrainedSystemWithoutPardiso) { EXPECT_NEAR(nodal.reaction[1][0], -5.0, 1.0e-12); } +TEST(CompleteResultContract, PreservesOriginsConnectivityAndBeamRecovery) { + const fesa::Domain domain = build_axial_domain(false); + + const fesa::AnalysisRunResult run = + fesa::LinearStaticAnalysis{}.run(domain); + + ASSERT_TRUE(run.succeeded); + ASSERT_TRUE(run.results.has_value()); + const fesa::ResultFrame& frame = run.results->steps[0].frames[0]; + EXPECT_EQ( + frame.nodal.origins, + (std::vector{ + {"BeamPart", "Beam-1", 2}, + {"BeamPart", "Beam-1", 1}, + })); + + ASSERT_EQ(frame.element.beams.size(), 1U); + const fesa::BeamElementFrame& beam = frame.element.beams[0]; + EXPECT_EQ(beam.element, fesa::ElementId{0}); + EXPECT_EQ( + beam.origin, + (fesa::EntityOrigin{"BeamPart", "Beam-1", 1})); + EXPECT_DOUBLE_EQ(beam.local_frame.ex.x, 1.0); + EXPECT_DOUBLE_EQ(beam.local_frame.ex.y, 0.0); + EXPECT_DOUBLE_EQ(beam.local_frame.ex.z, 0.0); + EXPECT_DOUBLE_EQ(beam.local_frame.ey.x, 0.0); + EXPECT_DOUBLE_EQ(beam.local_frame.ey.y, 1.0); + EXPECT_DOUBLE_EQ(beam.local_frame.ey.z, 0.0); + EXPECT_DOUBLE_EQ(beam.local_frame.ez.x, 0.0); + EXPECT_DOUBLE_EQ(beam.local_frame.ez.y, 0.0); + EXPECT_DOUBLE_EQ(beam.local_frame.ez.z, 1.0); + EXPECT_EQ(beam.end_results[0].end_node, fesa::NodeId{20}); + EXPECT_EQ(beam.end_results[1].end_node, fesa::NodeId{4}); + EXPECT_DOUBLE_EQ(beam.end_results[0].xi, -1.0); + EXPECT_DOUBLE_EQ(beam.end_results[1].xi, 1.0); + for (const fesa::BeamSectionResult& end : beam.end_results) { + EXPECT_NEAR(end.section_strain[0], 0.05, 1.0e-12); + EXPECT_NEAR(end.section_force[0], 10.0, 1.0e-12); + EXPECT_NEAR(end.centroid_sigma_xx, 5.0, 1.0e-12); + ASSERT_EQ(end.sigma_xx.size(), 2U); + EXPECT_NEAR(end.sigma_xx[0], 5.0, 1.0e-12); + EXPECT_NEAR(end.sigma_xx[1], 5.0, 1.0e-12); + } +} + } // namespace diff --git a/tests/unit/results/result_database_test.cpp b/tests/unit/results/result_database_test.cpp index 1d65dd6..74cb29f 100644 --- a/tests/unit/results/result_database_test.cpp +++ b/tests/unit/results/result_database_test.cpp @@ -17,6 +17,10 @@ std::array zeros() { fesa::ResultDatabase valid_database() { fesa::NodalFrame nodal{ {fesa::NodeId{0}, fesa::NodeId{1}}, + { + fesa::EntityOrigin{"BeamPart", "Beam-1", 10}, + fesa::EntityOrigin{"BeamPart", "Beam-1", 20}, + }, { zeros(), {1.0, 2.0, 3.0, 0.1, 0.2, 0.3}, @@ -26,7 +30,39 @@ fesa::ResultDatabase valid_database() { zeros(), }, }; - fesa::ResultFrame frame{1.0, std::move(nodal), {}}; + fesa::BeamElementFrame beam{ + fesa::ElementId{3}, + fesa::EntityOrigin{"BeamPart", "Beam-1", 30}, + { + fesa::Vec3{1.0, 0.0, 0.0}, + fesa::Vec3{0.0, 1.0, 0.0}, + fesa::Vec3{0.0, 0.0, 1.0}, + }, + {{ + { + -1.0, + fesa::NodeId{0}, + zeros(), + zeros(), + 0.0, + {1.0}, + }, + { + 1.0, + fesa::NodeId{1}, + zeros(), + zeros(), + 0.0, + {2.0}, + }, + }}, + }; + fesa::ResultFrame frame{ + 1.0, + std::move(nodal), + {{std::move(beam)}}, + {}, + }; fesa::ResultStep step{"Load", {std::move(frame)}}; return {"1.0.0", {std::move(step)}}; } @@ -72,6 +108,17 @@ TEST(NodalFrame, RejectsReactionSizeMismatch) { EXPECT_TRUE(has_diagnostic(status, "results.nodal_size_mismatch")); } +TEST(CompleteResultContract, RejectsNodeOriginSizeMismatch) { + auto database = valid_database(); + database.steps[0].frames[0].nodal.origins.pop_back(); + + const auto status = fesa::validate_result_database(database); + + EXPECT_FALSE(status.succeeded); + EXPECT_TRUE(has_diagnostic( + status, "results.nodal_origin_size_mismatch")); +} + TEST(NodalFrame, RejectsDuplicateNodeId) { auto database = valid_database(); database.steps[0].frames[0].nodal.node_ids[1] = fesa::NodeId{0}; @@ -135,4 +182,110 @@ TEST(ResultDatabase, RejectsNonfiniteFrameTime) { EXPECT_TRUE(has_diagnostic(status, "results.nonfinite_value")); } +TEST(CompleteResultContract, DeclaresCoordinatesAndComponentOrdering) { + EXPECT_EQ( + fesa::NodalFrame::coordinate_system, + fesa::FieldCoordinateSystem::global); + EXPECT_EQ( + fesa::NodalFrame::displacement_components, + (std::array{ + "Ux", "Uy", "Uz", "Rx", "Ry", "Rz"})); + EXPECT_EQ( + fesa::NodalFrame::reaction_components, + (std::array{ + "RFx", "RFy", "RFz", "RMx", "RMy", "RMz"})); + EXPECT_EQ( + fesa::BeamElementFrame::coordinate_system, + fesa::FieldCoordinateSystem::element_local); + EXPECT_EQ( + fesa::BeamElementFrame::section_strain_components, + (std::array{ + "epsilon", "gamma_y", "gamma_z", "kappa_x", "kappa_y", + "kappa_z"})); + EXPECT_EQ( + fesa::BeamElementFrame::section_force_components, + (std::array{ + "N", "Vy", "Vz", "T", "My", "Mz"})); + EXPECT_EQ( + fesa::BeamElementFrame::axial_stress_component, + std::string_view{"sigma_xx"}); +} + +TEST(ElementFrame, AcceptsFiniteConnectedBeamResults) { + const auto status = fesa::validate_result_database(valid_database()); + + EXPECT_TRUE(status.succeeded); + EXPECT_TRUE(status.diagnostics.empty()); +} + +TEST(ElementFrame, RejectsDuplicateElementId) { + auto database = valid_database(); + auto& beams = database.steps[0].frames[0].element.beams; + beams.push_back(beams[0]); + + const auto status = fesa::validate_result_database(database); + + EXPECT_FALSE(status.succeeded); + EXPECT_TRUE(has_diagnostic(status, "results.duplicate_element_id")); +} + +TEST(ElementFrame, RejectsDuplicateEndNode) { + auto database = valid_database(); + auto& ends = database.steps[0].frames[0] + .element.beams[0].end_results; + ends[1].end_node = ends[0].end_node; + + const auto status = fesa::validate_result_database(database); + + EXPECT_FALSE(status.succeeded); + EXPECT_TRUE(has_diagnostic(status, "results.duplicate_beam_end_node")); +} + +TEST(ElementFrame, RejectsWrongEndConnectivity) { + auto database = valid_database(); + database.steps[0].frames[0] + .element.beams[0].end_results[0].xi = 1.0; + + const auto status = fesa::validate_result_database(database); + + EXPECT_FALSE(status.succeeded); + EXPECT_TRUE(has_diagnostic(status, "results.invalid_beam_connectivity")); +} + +TEST(ElementFrame, RejectsEndNodeAbsentFromNodalFrame) { + auto database = valid_database(); + database.steps[0].frames[0] + .element.beams[0].end_results[1].end_node = fesa::NodeId{99}; + + const auto status = fesa::validate_result_database(database); + + EXPECT_FALSE(status.succeeded); + EXPECT_TRUE(has_diagnostic(status, "results.invalid_beam_connectivity")); +} + +TEST(ElementFrame, RejectsNonfiniteLocalFrameAndSectionValue) { + auto database = valid_database(); + auto& beam = database.steps[0].frames[0].element.beams[0]; + beam.local_frame.ey.y = std::numeric_limits::infinity(); + beam.end_results[1].section_force[4] = + std::numeric_limits::quiet_NaN(); + + const auto status = fesa::validate_result_database(database); + + EXPECT_FALSE(status.succeeded); + EXPECT_TRUE(has_diagnostic(status, "results.nonfinite_value")); +} + +TEST(ElementFrame, RejectsMismatchedRecoveryPointCount) { + auto database = valid_database(); + database.steps[0].frames[0] + .element.beams[0].end_results[1].sigma_xx.push_back(3.0); + + const auto status = fesa::validate_result_database(database); + + EXPECT_FALSE(status.succeeded); + EXPECT_TRUE(has_diagnostic( + status, "results.recovery_point_count_mismatch")); +} + } // namespace