From 98c13c2ec233beaf93fcd3438129fa961de628f8 Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Sun, 2 Aug 2026 03:22:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(beam-reference-qualification):=20step=200?= =?UTF-8?q?=20=E2=80=94=20comparison-metric-and-entity-matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 1 + include/fesa/validation/comparison.hpp | 61 +++ src/fesa/validation/comparison.cpp | 442 ++++++++++++++++++++++ tests/CMakeLists.txt | 29 ++ tests/unit/validation/comparison_test.cpp | 345 +++++++++++++++++ 5 files changed, 878 insertions(+) create mode 100644 include/fesa/validation/comparison.hpp create mode 100644 src/fesa/validation/comparison.cpp create mode 100644 tests/unit/validation/comparison_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3753b4a..f81a308 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,7 @@ add_library(fesa_core STATIC src/fesa/model/domain_builder.cpp src/fesa/results/result_database.cpp src/fesa/solvers/linear/pardiso_linear_solver.cpp + src/fesa/validation/comparison.cpp ) target_include_directories(fesa_core diff --git a/include/fesa/validation/comparison.hpp b/include/fesa/validation/comparison.hpp new file mode 100644 index 0000000..963504c --- /dev/null +++ b/include/fesa/validation/comparison.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +namespace fesa { + +enum class ReferenceQuantity { + displacement, + reaction, + internal_force, + centroid_stress +}; + +struct Tolerance final { + double relative; + double absolute_scale; +}; + +struct ResultPosition final { + std::string instance_name; + std::int64_t entity_label; + std::optional end_node_label; +}; + +struct ComparisonSample final { + ReferenceQuantity quantity; + ResultPosition position; + std::vector reference; + std::vector actual; + Tolerance tolerance; +}; + +struct ComparisonReport final { + bool passed; + double maximum_normalized_error; + std::vector failures; +}; + +struct ComparisonSampleMatch final { + std::optional sample; + std::vector failures; +}; + +[[nodiscard]] ComparisonSampleMatch make_comparison_sample( + const ResultFrame& frame, + ReferenceQuantity quantity, + const ResultPosition& position, + std::span reference, + Tolerance tolerance); + +[[nodiscard]] ComparisonReport compare_samples( + std::span samples); + +} // namespace fesa diff --git a/src/fesa/validation/comparison.cpp b/src/fesa/validation/comparison.cpp new file mode 100644 index 0000000..75b2fe6 --- /dev/null +++ b/src/fesa/validation/comparison.cpp @@ -0,0 +1,442 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { +namespace { + +using PositionKey = std::tuple< + ReferenceQuantity, + std::string, + std::int64_t, + std::optional>; + +void add_failure( + std::vector& failures, + std::string code, + std::string message) { + failures.push_back({ + DiagnosticStage::validation, + Severity::error, + std::move(code), + std::move(message), + std::nullopt, + }); +} + +std::string_view quantity_name(const ReferenceQuantity quantity) { + switch (quantity) { + case ReferenceQuantity::displacement: + return "displacement"; + case ReferenceQuantity::reaction: + return "reaction"; + case ReferenceQuantity::internal_force: + return "internal_force"; + case ReferenceQuantity::centroid_stress: + return "centroid_stress"; + } + return "unknown"; +} + +std::size_t expected_component_count( + const ReferenceQuantity quantity) { + switch (quantity) { + case ReferenceQuantity::displacement: + case ReferenceQuantity::reaction: + case ReferenceQuantity::internal_force: + return 6; + case ReferenceQuantity::centroid_stress: + return 1; + } + return 0; +} + +std::string component_name( + const ReferenceQuantity quantity, + const std::size_t index) { + switch (quantity) { + case ReferenceQuantity::displacement: + return std::string{NodalFrame::displacement_components[index]}; + case ReferenceQuantity::reaction: + return std::string{NodalFrame::reaction_components[index]}; + case ReferenceQuantity::internal_force: + return std::string{ + BeamElementFrame::section_force_components[index]}; + case ReferenceQuantity::centroid_stress: + return std::string{BeamElementFrame::axial_stress_component}; + } + return "unknown"; +} + +std::string number_text(const double value) { + std::ostringstream stream; + stream << std::setprecision(std::numeric_limits::max_digits10) + << value; + return stream.str(); +} + +std::string tolerance_text(const Tolerance tolerance) { + return " relative_tolerance=" + number_text(tolerance.relative) + + " absolute_scale=" + number_text(tolerance.absolute_scale); +} + +std::string position_text( + const ReferenceQuantity quantity, + const ResultPosition& position) { + std::string text = "quantity=" + std::string{quantity_name(quantity)} + + " instance=" + position.instance_name + + " entity=" + std::to_string(position.entity_label); + if (position.end_node_label.has_value()) { + text += " end_node=" + + std::to_string(*position.end_node_label); + } else { + text += " end_node=n/a"; + } + return text; +} + +std::string scalar_failure_text( + const ComparisonSample& sample, + const std::size_t component, + const double normalized_error) { + return position_text(sample.quantity, sample.position) + + " component=" + component_name(sample.quantity, component) + + " reference=" + number_text(sample.reference[component]) + + " actual=" + number_text(sample.actual[component]) + + " normalized_error=" + number_text(normalized_error) + + tolerance_text(sample.tolerance); +} + +std::string unevaluable_failure_text( + const ReferenceQuantity quantity, + const ResultPosition& position, + const Tolerance tolerance, + const std::string_view reason) { + return position_text(quantity, position) + + " component=n/a reference=n/a actual=n/a " + "normalized_error=inf" + tolerance_text(tolerance) + + " reason=" + std::string{reason}; +} + +bool origin_matches( + const EntityOrigin& origin, + const std::string& instance_name, + const std::int64_t local_label) { + return origin.instance_name == instance_name && + origin.local_label == local_label; +} + +ComparisonSampleMatch matching_failure( + const ReferenceQuantity quantity, + const ResultPosition& position, + const Tolerance tolerance, + std::string code, + const std::string_view reason) { + std::vector failures; + add_failure( + failures, + std::move(code), + unevaluable_failure_text( + quantity, position, tolerance, reason)); + return {std::nullopt, std::move(failures)}; +} + +std::vector as_vector(const std::array& values) { + return {values.begin(), values.end()}; +} + +} // namespace + +ComparisonSampleMatch make_comparison_sample( + const ResultFrame& frame, + const ReferenceQuantity quantity, + const ResultPosition& position, + const std::span reference, + const Tolerance tolerance) { + std::vector actual; + + if ( + quantity == ReferenceQuantity::displacement || + quantity == ReferenceQuantity::reaction) { + if (position.end_node_label.has_value()) { + return matching_failure( + quantity, + position, + tolerance, + "validation.invalid_result_position", + "nodal_position_has_end_node"); + } + + std::optional matched_index; + for ( + std::size_t index = 0; + index < frame.nodal.origins.size(); + ++index) { + if (origin_matches( + frame.nodal.origins[index], + position.instance_name, + position.entity_label)) { + if (matched_index.has_value()) { + return matching_failure( + quantity, + position, + tolerance, + "validation.unknown_result_origin", + "ambiguous_result_origin"); + } + matched_index = index; + } + } + if (!matched_index.has_value()) { + return matching_failure( + quantity, + position, + tolerance, + "validation.unknown_result_origin", + "unknown_result_origin"); + } + + const auto& field = + quantity == ReferenceQuantity::displacement + ? frame.nodal.displacement + : frame.nodal.reaction; + if (*matched_index >= field.size()) { + return matching_failure( + quantity, + position, + tolerance, + "validation.component_count_mismatch", + "missing_actual_components"); + } + actual = as_vector(field[*matched_index]); + } else { + if (!position.end_node_label.has_value()) { + return matching_failure( + quantity, + position, + tolerance, + "validation.invalid_element_node_pair", + "missing_end_node"); + } + + const BeamElementFrame* matched_beam = nullptr; + for (const BeamElementFrame& beam : frame.element.beams) { + if (origin_matches( + beam.origin, + position.instance_name, + position.entity_label)) { + if (matched_beam != nullptr) { + return matching_failure( + quantity, + position, + tolerance, + "validation.unknown_result_origin", + "ambiguous_result_origin"); + } + matched_beam = &beam; + } + } + if (matched_beam == nullptr) { + return matching_failure( + quantity, + position, + tolerance, + "validation.unknown_result_origin", + "unknown_result_origin"); + } + + std::optional end_node; + for ( + std::size_t index = 0; + index < frame.nodal.origins.size() && + index < frame.nodal.node_ids.size(); + ++index) { + if (origin_matches( + frame.nodal.origins[index], + position.instance_name, + *position.end_node_label)) { + if (end_node.has_value()) { + return matching_failure( + quantity, + position, + tolerance, + "validation.unknown_result_origin", + "ambiguous_end_node_origin"); + } + end_node = frame.nodal.node_ids[index]; + } + } + if (!end_node.has_value()) { + return matching_failure( + quantity, + position, + tolerance, + "validation.unknown_result_origin", + "unknown_end_node_origin"); + } + + const BeamSectionResult* matched_end = nullptr; + for (const BeamSectionResult& end : matched_beam->end_results) { + if (end.end_node == *end_node) { + matched_end = &end; + break; + } + } + if (matched_end == nullptr) { + return matching_failure( + quantity, + position, + tolerance, + "validation.invalid_element_node_pair", + "node_is_not_element_end"); + } + + if (quantity == ReferenceQuantity::internal_force) { + actual = as_vector(matched_end->section_force); + } else { + actual = {matched_end->centroid_sigma_xx}; + } + } + + ComparisonSample matched{ + quantity, + position, + {reference.begin(), reference.end()}, + std::move(actual), + tolerance, + }; + return {std::move(matched), {}}; +} + +ComparisonReport compare_samples( + const std::span samples) { + ComparisonReport report{true, 0.0, {}}; + std::set positions; + + for (const ComparisonSample& sample : samples) { + const PositionKey key{ + sample.quantity, + sample.position.instance_name, + sample.position.entity_label, + sample.position.end_node_label, + }; + if (!positions.insert(key).second) { + add_failure( + report.failures, + "validation.duplicate_result_position", + unevaluable_failure_text( + sample.quantity, + sample.position, + sample.tolerance, + "duplicate_result_position")); + report.maximum_normalized_error = + std::numeric_limits::infinity(); + continue; + } + + const std::size_t expected = + expected_component_count(sample.quantity); + if ( + sample.reference.size() != expected || + sample.actual.size() != expected) { + add_failure( + report.failures, + "validation.component_count_mismatch", + unevaluable_failure_text( + sample.quantity, + sample.position, + sample.tolerance, + "component_count_mismatch") + + " expected=" + std::to_string(expected) + + " reference_count=" + + std::to_string(sample.reference.size()) + + " actual_count=" + + std::to_string(sample.actual.size())); + report.maximum_normalized_error = + std::numeric_limits::infinity(); + continue; + } + + if ( + !std::isfinite(sample.tolerance.relative) || + !std::isfinite(sample.tolerance.absolute_scale) || + sample.tolerance.relative < 0.0 || + sample.tolerance.absolute_scale < 0.0) { + add_failure( + report.failures, + "validation.invalid_tolerance", + unevaluable_failure_text( + sample.quantity, + sample.position, + sample.tolerance, + "invalid_tolerance")); + report.maximum_normalized_error = + std::numeric_limits::infinity(); + continue; + } + + for (std::size_t component = 0; component < expected; ++component) { + const double reference = sample.reference[component]; + const double actual = sample.actual[component]; + if (!std::isfinite(reference) || !std::isfinite(actual)) { + add_failure( + report.failures, + "validation.nonfinite_comparison_value", + scalar_failure_text( + sample, + component, + std::numeric_limits::infinity())); + report.maximum_normalized_error = + std::numeric_limits::infinity(); + continue; + } + + const double denominator = + sample.tolerance.absolute_scale + + sample.tolerance.relative * std::abs(reference); + if (!(denominator > 0.0) || !std::isfinite(denominator)) { + add_failure( + report.failures, + "validation.invalid_tolerance", + scalar_failure_text( + sample, + component, + std::numeric_limits::infinity())); + report.maximum_normalized_error = + std::numeric_limits::infinity(); + continue; + } + + const double normalized_error = + std::abs(actual - reference) / denominator; + report.maximum_normalized_error = std::max( + report.maximum_normalized_error, + normalized_error); + if (!(normalized_error <= 1.0)) { + add_failure( + report.failures, + "validation.tolerance_exceeded", + scalar_failure_text( + sample, component, normalized_error)); + } + } + } + + report.passed = report.failures.empty(); + return report; +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index be62c98..471cca5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -743,3 +743,32 @@ set_property( PROPERTY ENVIRONMENT_MODIFICATION ${FESA_DEPENDENCY_RUNTIME_MODIFICATIONS} ) + +add_executable(fesa_validation_comparison_tests + unit/validation/comparison_test.cpp +) + +target_compile_features( + fesa_validation_comparison_tests PRIVATE cxx_std_20 +) +target_compile_options( + fesa_validation_comparison_tests PRIVATE /W4 /permissive- /EHsc +) +target_link_libraries( + fesa_validation_comparison_tests + PRIVATE + fesa_core + GTest::gtest_main +) + +add_test( + NAME ComparisonMetric + COMMAND "$" + --gtest_filter=ComparisonMetric.* +) + +add_test( + NAME EntityMatching + COMMAND "$" + --gtest_filter=EntityMatching.* +) diff --git a/tests/unit/validation/comparison_test.cpp b/tests/unit/validation/comparison_test.cpp new file mode 100644 index 0000000..b1c7820 --- /dev/null +++ b/tests/unit/validation/comparison_test.cpp @@ -0,0 +1,345 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +std::array values( + const double first, + const double second, + const double third, + const double fourth, + const double fifth, + const double sixth) { + return {first, second, third, fourth, fifth, sixth}; +} + +fesa::ResultFrame result_frame() { + fesa::NodalFrame nodal{ + {fesa::NodeId{10}, fesa::NodeId{11}, fesa::NodeId{12}}, + { + fesa::EntityOrigin{"BeamPart", "Part-1-1", 101}, + fesa::EntityOrigin{"BeamPart", "Part-1-1", 102}, + fesa::EntityOrigin{"BeamPart", "Part-1-1", 103}, + }, + { + values(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), + values(7.0, 8.0, 9.0, 10.0, 11.0, 12.0), + values(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + }, + { + values(13.0, 14.0, 15.0, 16.0, 17.0, 18.0), + values(19.0, 20.0, 21.0, 22.0, 23.0, 24.0), + values(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + }, + }; + fesa::BeamElementFrame beam{ + fesa::ElementId{20}, + fesa::EntityOrigin{"BeamPart", "Part-1-1", 501}, + { + 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{10}, + values(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + values(25.0, 26.0, 27.0, 28.0, 29.0, 30.0), + 31.0, + {}, + }, + { + 1.0, + fesa::NodeId{11}, + values(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + values(32.0, 33.0, 34.0, 35.0, 36.0, 37.0), + 38.0, + {}, + }, + }}, + }; + return {1.0, std::move(nodal), {{std::move(beam)}}, {}}; +} + +fesa::ComparisonSample sample( + const fesa::ReferenceQuantity quantity, + fesa::ResultPosition position, + std::vector reference, + std::vector actual, + const fesa::Tolerance tolerance) { + return { + quantity, + std::move(position), + std::move(reference), + std::move(actual), + tolerance, + }; +} + +bool has_failure( + const std::vector& failures, + const std::string_view code) { + return std::ranges::any_of( + failures, + [code](const fesa::Diagnostic& failure) { + return failure.stage == fesa::DiagnosticStage::validation && + failure.severity == fesa::Severity::error && + failure.code == code; + }); +} + +TEST(ComparisonMetric, RejectsNearZeroErrorBeyondAbsoluteScale) { + const auto input = sample( + fesa::ReferenceQuantity::displacement, + {"Part-1-1", 101, std::nullopt}, + {0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, + {2.0e-9, 0.0, 0.0, 0.0, 0.0, 0.0}, + {1.0e-5, 1.0e-9}); + + const auto report = fesa::compare_samples( + std::span{&input, 1}); + + ASSERT_FALSE(report.passed); + EXPECT_DOUBLE_EQ(report.maximum_normalized_error, 2.0); + ASSERT_EQ(report.failures.size(), 1U); + EXPECT_EQ(report.failures[0].code, "validation.tolerance_exceeded"); + EXPECT_NE( + report.failures[0].message.find("quantity=displacement"), + std::string::npos); + EXPECT_NE( + report.failures[0].message.find("entity=101"), + std::string::npos); + EXPECT_NE( + report.failures[0].message.find("component=Ux"), + std::string::npos); + EXPECT_NE( + report.failures[0].message.find("reference="), + std::string::npos); + EXPECT_NE( + report.failures[0].message.find("actual="), + std::string::npos); + EXPECT_NE( + report.failures[0].message.find("normalized_error="), + std::string::npos); + EXPECT_NE( + report.failures[0].message.find("relative_tolerance="), + std::string::npos); + EXPECT_NE( + report.failures[0].message.find("absolute_scale="), + std::string::npos); +} + +TEST(ComparisonMetric, RejectsRepresentativeLargeRelativeError) { + const auto input = sample( + fesa::ReferenceQuantity::reaction, + {"Part-1-1", 101, std::nullopt}, + {1.0e6, 1.0e6, 1.0e6, 1.0e6, 1.0e6, 1.0e6}, + {1.0e6 + 20.0, 1.0e6, 1.0e6, 1.0e6, 1.0e6, 1.0e6}, + {1.0e-5, 0.0}); + + const auto report = fesa::compare_samples( + std::span{&input, 1}); + + EXPECT_FALSE(report.passed); + EXPECT_DOUBLE_EQ(report.maximum_normalized_error, 2.0); + EXPECT_TRUE(has_failure( + report.failures, "validation.tolerance_exceeded")); +} + +TEST(ComparisonMetric, AcceptsErrorsAtTheExplicitToleranceBoundary) { + const std::vector inputs{ + sample( + fesa::ReferenceQuantity::displacement, + {"Part-1-1", 101, std::nullopt}, + {0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, + {1.0e-9, 0.0, 0.0, 0.0, 0.0, 0.0}, + {1.0e-5, 1.0e-9}), + sample( + fesa::ReferenceQuantity::reaction, + {"Part-1-1", 101, std::nullopt}, + {1.0e6, 1.0e6, 1.0e6, 1.0e6, 1.0e6, 1.0e6}, + {1.0e6 + 10.0, 1.0e6, 1.0e6, 1.0e6, 1.0e6, 1.0e6}, + {1.0e-5, 0.0}), + }; + + const auto report = fesa::compare_samples(inputs); + + EXPECT_TRUE(report.passed); + EXPECT_DOUBLE_EQ(report.maximum_normalized_error, 1.0); + EXPECT_TRUE(report.failures.empty()); +} + +TEST(ComparisonMetric, RejectsNonfiniteValues) { + const auto input = sample( + fesa::ReferenceQuantity::centroid_stress, + {"Part-1-1", 501, 101}, + {10.0}, + {std::numeric_limits::quiet_NaN()}, + {1.0e-5, 1.0e-6}); + + const auto report = fesa::compare_samples( + std::span{&input, 1}); + + EXPECT_FALSE(report.passed); + EXPECT_TRUE(std::isinf(report.maximum_normalized_error)); + EXPECT_TRUE(has_failure( + report.failures, "validation.nonfinite_comparison_value")); +} + +TEST(ComparisonMetric, RejectsDuplicateQuantityAndPosition) { + const std::vector inputs{ + sample( + fesa::ReferenceQuantity::displacement, + {"Part-1-1", 101, std::nullopt}, + {0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, + {0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, + {1.0e-5, 1.0e-9}), + sample( + fesa::ReferenceQuantity::displacement, + {"Part-1-1", 101, std::nullopt}, + {1.0, 1.0, 1.0, 1.0, 1.0, 1.0}, + {1.0, 1.0, 1.0, 1.0, 1.0, 1.0}, + {1.0e-5, 1.0e-9}), + }; + + const auto report = fesa::compare_samples(inputs); + + EXPECT_FALSE(report.passed); + EXPECT_TRUE(std::isinf(report.maximum_normalized_error)); + EXPECT_TRUE(has_failure( + report.failures, "validation.duplicate_result_position")); +} + +TEST(ComparisonMetric, RejectsComponentCountMismatch) { + const auto input = sample( + fesa::ReferenceQuantity::internal_force, + {"Part-1-1", 501, 101}, + {1.0, 2.0, 3.0, 4.0, 5.0, 6.0}, + {1.0, 2.0, 3.0, 4.0, 5.0}, + {1.0e-5, 1.0e-9}); + + const auto report = fesa::compare_samples( + std::span{&input, 1}); + + EXPECT_FALSE(report.passed); + EXPECT_TRUE(std::isinf(report.maximum_normalized_error)); + EXPECT_TRUE(has_failure( + report.failures, "validation.component_count_mismatch")); +} + +TEST(EntityMatching, MatchesNodalResultByInstanceAndExternalLabel) { + const auto frame = result_frame(); + const std::array reference{0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; + + const auto match = fesa::make_comparison_sample( + frame, + fesa::ReferenceQuantity::displacement, + {"Part-1-1", 102, std::nullopt}, + reference, + {1.0e-5, 1.0e-9}); + + ASSERT_TRUE(match.sample.has_value()); + EXPECT_TRUE(match.failures.empty()); + EXPECT_EQ( + match.sample->actual, + (std::vector{7.0, 8.0, 9.0, 10.0, 11.0, 12.0})); +} + +TEST(EntityMatching, MatchesReactionComponentsWithoutUsingDisplacement) { + const auto frame = result_frame(); + const std::array reference{0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; + + const auto match = fesa::make_comparison_sample( + frame, + fesa::ReferenceQuantity::reaction, + {"Part-1-1", 101, std::nullopt}, + reference, + {1.0e-5, 1.0e-9}); + + ASSERT_TRUE(match.sample.has_value()); + EXPECT_TRUE(match.failures.empty()); + EXPECT_EQ( + match.sample->actual, + (std::vector{13.0, 14.0, 15.0, 16.0, 17.0, 18.0})); +} + +TEST(EntityMatching, MatchesElementResultByElementAndEndNodeLabels) { + const auto frame = result_frame(); + const std::array reference{0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; + + const auto match = fesa::make_comparison_sample( + frame, + fesa::ReferenceQuantity::internal_force, + {"Part-1-1", 501, 102}, + reference, + {1.0e-5, 1.0e-9}); + + ASSERT_TRUE(match.sample.has_value()); + EXPECT_TRUE(match.failures.empty()); + EXPECT_EQ( + match.sample->actual, + (std::vector{32.0, 33.0, 34.0, 35.0, 36.0, 37.0})); +} + +TEST(EntityMatching, MatchesCentroidStressAtTheRequestedElementEnd) { + const auto frame = result_frame(); + const std::array reference{0.0}; + + const auto match = fesa::make_comparison_sample( + frame, + fesa::ReferenceQuantity::centroid_stress, + {"Part-1-1", 501, 101}, + reference, + {1.0e-5, 1.0e-9}); + + ASSERT_TRUE(match.sample.has_value()); + EXPECT_TRUE(match.failures.empty()); + EXPECT_EQ(match.sample->actual, (std::vector{31.0})); +} + +TEST(EntityMatching, RejectsUnknownResultOrigin) { + const auto frame = result_frame(); + const std::array reference{0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; + + const auto match = fesa::make_comparison_sample( + frame, + fesa::ReferenceQuantity::reaction, + {"Part-1-1", 999, std::nullopt}, + reference, + {1.0e-5, 1.0e-9}); + + EXPECT_FALSE(match.sample.has_value()); + EXPECT_TRUE(has_failure( + match.failures, "validation.unknown_result_origin")); +} + +TEST(EntityMatching, RejectsNodeThatIsNotAnEndOfTheElement) { + const auto frame = result_frame(); + const std::array reference{0.0}; + + const auto match = fesa::make_comparison_sample( + frame, + fesa::ReferenceQuantity::centroid_stress, + {"Part-1-1", 501, 103}, + reference, + {1.0e-5, 1.0e-9}); + + EXPECT_FALSE(match.sample.has_value()); + EXPECT_TRUE(has_failure( + match.failures, "validation.invalid_element_node_pair")); +} + +} // namespace