diff --git a/CMakeLists.txt b/CMakeLists.txt index f81a308..5254573 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,7 @@ add_library(fesa_core STATIC src/fesa/results/result_database.cpp src/fesa/solvers/linear/pardiso_linear_solver.cpp src/fesa/validation/comparison.cpp + src/fesa/validation/reference_csv.cpp ) target_include_directories(fesa_core diff --git a/include/fesa/validation/reference_csv.hpp b/include/fesa/validation/reference_csv.hpp new file mode 100644 index 0000000..2903eb2 --- /dev/null +++ b/include/fesa/validation/reference_csv.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace fesa { + +struct ReferenceRow final { + ReferenceQuantity quantity; + ResultPosition position; + std::vector values; +}; + +struct ReferenceCsvReadResult final { + std::vector rows; + std::vector diagnostics; +}; + +[[nodiscard]] ReferenceCsvReadResult read_reference_csv( + ReferenceQuantity quantity, + const std::filesystem::path& path, + std::string_view single_instance_name); + +} // namespace fesa diff --git a/src/fesa/validation/reference_csv.cpp b/src/fesa/validation/reference_csv.cpp new file mode 100644 index 0000000..a17e0ae --- /dev/null +++ b/src/fesa/validation/reference_csv.cpp @@ -0,0 +1,446 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { +namespace { + +constexpr std::string_view instance_column{"Part Instance Name"}; +constexpr std::string_view node_column{"Node Label"}; +constexpr std::string_view element_column{"Element Label"}; + +using PositionKey = + std::tuple>; +using ColumnIndices = + std::map>; + +std::size_t column_index( + const ColumnIndices& column_indices, + const std::string_view name) { + return column_indices.find(name)->second; +} + +std::string_view trim(const std::string_view value) { + constexpr std::string_view whitespace{" \t\f\v\r\n"}; + const std::size_t first = value.find_first_not_of(whitespace); + if (first == std::string_view::npos) { + return {}; + } + const std::size_t last = value.find_last_not_of(whitespace); + return value.substr(first, last - first + 1U); +} + +std::vector split_fields(const std::string_view line) { + std::vector fields; + std::size_t first = 0U; + while (true) { + const std::size_t comma = line.find(',', first); + const std::string_view field = + comma == std::string_view::npos + ? line.substr(first) + : line.substr(first, comma - first); + fields.emplace_back(trim(field)); + if (comma == std::string_view::npos) { + break; + } + first = comma + 1U; + } + return fields; +} + +ReferenceCsvReadResult failure( + const DiagnosticStage stage, + std::string code, + std::string message, + const std::filesystem::path& path, + const std::size_t line) { + std::vector diagnostics; + diagnostics.push_back({ + stage, + Severity::error, + std::move(code), + std::move(message), + SourceLocation{path, line, line == 0U ? 0U : 1U}, + }); + return {{}, std::move(diagnostics)}; +} + +ReferenceCsvReadResult validation_failure( + std::string code, + std::string message, + const std::filesystem::path& path, + const std::size_t line) { + return failure( + DiagnosticStage::validation, + std::move(code), + std::move(message), + path, + line); +} + +bool is_valid_utf8(const std::string_view text) { + std::size_t index = 0U; + while (index < text.size()) { + const auto first = static_cast(text[index]); + if (first <= 0x7FU) { + ++index; + continue; + } + + std::size_t continuation_count = 0U; + std::uint32_t code_point = 0U; + std::uint32_t minimum = 0U; + if (first >= 0xC2U && first <= 0xDFU) { + continuation_count = 1U; + code_point = first & 0x1FU; + minimum = 0x80U; + } else if (first >= 0xE0U && first <= 0xEFU) { + continuation_count = 2U; + code_point = first & 0x0FU; + minimum = 0x800U; + } else if (first >= 0xF0U && first <= 0xF4U) { + continuation_count = 3U; + code_point = first & 0x07U; + minimum = 0x10000U; + } else { + return false; + } + if (index + continuation_count >= text.size()) { + return false; + } + for (std::size_t offset = 1U; offset <= continuation_count; ++offset) { + const auto continuation = + static_cast(text[index + offset]); + if ((continuation & 0xC0U) != 0x80U) { + return false; + } + code_point = (code_point << 6U) | (continuation & 0x3FU); + } + if (code_point < minimum || code_point > 0x10FFFFU || + (code_point >= 0xD800U && code_point <= 0xDFFFU)) { + return false; + } + index += continuation_count + 1U; + } + return true; +} + +std::size_t line_at_offset( + const std::string_view text, + const std::size_t offset) { + return 1U + static_cast(std::ranges::count( + text.substr(0U, offset), '\n')); +} + +std::vector required_columns( + const ReferenceQuantity quantity) { + switch (quantity) { + case ReferenceQuantity::displacement: + return { + node_column, + "U-U1", + "U-U2", + "U-U3", + "UR-UR1", + "UR-UR2", + "UR-UR3", + }; + case ReferenceQuantity::reaction: + return { + node_column, + "RF-RF1", + "RF-RF2", + "RF-RF3", + "RM-RM1", + "RM-RM2", + "RM-RM3", + }; + case ReferenceQuantity::internal_force: + return { + element_column, + node_column, + "SF-SF1", + "SF-SF2", + "SF-SF3", + "SM-SM1", + "SM-SM2", + "SM-SM3", + }; + case ReferenceQuantity::centroid_stress: + return {element_column, node_column, "Sxx"}; + } + return {}; +} + +std::vector value_columns( + const ReferenceQuantity quantity) { + switch (quantity) { + case ReferenceQuantity::displacement: + return {"U-U1", "U-U2", "U-U3", "UR-UR1", "UR-UR2", "UR-UR3"}; + case ReferenceQuantity::reaction: + return {"RF-RF1", "RF-RF2", "RF-RF3", "RM-RM1", "RM-RM2", "RM-RM3"}; + case ReferenceQuantity::internal_force: + return {"SF-SF1", "SF-SF2", "SF-SF3", "SM-SM1", "SM-SM2", "SM-SM3"}; + case ReferenceQuantity::centroid_stress: + return {"Sxx"}; + } + return {}; +} + +bool parse_positive_label( + std::string_view text, + std::int64_t& value) { + if (text.starts_with('+')) { + text.remove_prefix(1U); + } + const auto parsed = std::from_chars( + text.data(), text.data() + text.size(), value); + return !text.empty() && parsed.ec == std::errc{} && + parsed.ptr == text.data() + text.size() && value > 0; +} + +bool parse_finite_double(std::string_view text, double& value) { + if (text.starts_with('+')) { + text.remove_prefix(1U); + } + const auto parsed = std::from_chars( + text.data(), + text.data() + text.size(), + value, + std::chars_format::general); + return !text.empty() && parsed.ec == std::errc{} && + parsed.ptr == text.data() + text.size() && + std::isfinite(value); +} + +} // namespace + +ReferenceCsvReadResult read_reference_csv( + const ReferenceQuantity quantity, + const std::filesystem::path& path, + const std::string_view single_instance_name) { + std::ifstream file{path, std::ios::binary}; + if (!file) { + return failure( + DiagnosticStage::io, + "validation.reference_csv_open_failed", + "Unable to open reference CSV file.", + path, + 0U); + } + + std::string bytes{ + std::istreambuf_iterator{file}, + std::istreambuf_iterator{}, + }; + if (file.bad()) { + return failure( + DiagnosticStage::io, + "validation.reference_csv_read_failed", + "Failed while reading reference CSV file.", + path, + 0U); + } + + constexpr std::string_view bom{"\xEF\xBB\xBF"}; + if (bytes.starts_with(bom)) { + bytes.erase(0U, bom.size()); + } + const std::size_t misplaced_bom = bytes.find(bom); + if (misplaced_bom != std::string::npos || !is_valid_utf8(bytes)) { + return validation_failure( + "validation.reference_csv_invalid_encoding", + "Reference CSV must be UTF-8 with an optional BOM only at the file start.", + path, + misplaced_bom == std::string::npos + ? 1U + : line_at_offset(bytes, misplaced_bom)); + } + + std::istringstream input{bytes}; + std::string line; + if (!std::getline(input, line)) { + return validation_failure( + "validation.reference_csv_missing_header", + "Reference CSV is missing its header row.", + path, + 1U); + } + + const std::vector headers = split_fields(line); + ColumnIndices column_indices; + for (std::size_t index = 0U; index < headers.size(); ++index) { + if (headers[index].empty() || + !column_indices.emplace(headers[index], index).second) { + return validation_failure( + "validation.reference_csv_duplicate_column", + "Reference CSV contains an empty or duplicate column name.", + path, + 1U); + } + } + + const std::vector required = required_columns(quantity); + for (const std::string_view name : required) { + if (!column_indices.contains(name)) { + return validation_failure( + "validation.reference_csv_missing_column", + "Reference CSV is missing required column '" + + std::string{name} + "'.", + path, + 1U); + } + } + const bool has_instance = column_indices.contains(instance_column); + if (!has_instance && single_instance_name.empty()) { + return validation_failure( + "validation.reference_csv_missing_column", + "Reference CSV omits 'Part Instance Name' without a single-Instance name.", + path, + 1U); + } + + std::set> allowed_columns; + allowed_columns.emplace(instance_column); + for (const std::string_view name : required) { + allowed_columns.emplace(name); + } + for (const std::string& header : headers) { + if (!allowed_columns.contains(header)) { + return validation_failure( + "validation.reference_csv_unsupported_column", + "Reference CSV contains unsupported column '" + header + "'.", + path, + 1U); + } + } + + const std::vector components = value_columns(quantity); + std::vector rows; + std::set positions; + std::size_t line_number = 1U; + while (std::getline(input, line)) { + ++line_number; + if (trim(line).empty()) { + continue; + } + const std::vector fields = split_fields(line); + if (fields.size() != headers.size()) { + return validation_failure( + "validation.reference_csv_invalid_row", + "Reference CSV row field count does not match the header.", + path, + line_number); + } + + std::string instance_name = has_instance + ? fields[column_index( + column_indices, + instance_column)] + : std::string{single_instance_name}; + if (instance_name.empty()) { + return validation_failure( + "validation.reference_csv_invalid_row", + "Reference CSV row has an empty Instance name.", + path, + line_number); + } + + std::int64_t entity_label = 0; + const std::string_view entity_column = + quantity == ReferenceQuantity::displacement || + quantity == ReferenceQuantity::reaction + ? node_column + : element_column; + if (!parse_positive_label( + fields[column_index(column_indices, entity_column)], + entity_label)) { + return validation_failure( + "validation.reference_csv_invalid_number", + "Reference CSV entity label must be a positive integer.", + path, + line_number); + } + + std::optional end_node_label; + if (quantity == ReferenceQuantity::internal_force || + quantity == ReferenceQuantity::centroid_stress) { + std::int64_t parsed_end_node = 0; + if (!parse_positive_label( + fields[column_index(column_indices, node_column)], + parsed_end_node)) { + return validation_failure( + "validation.reference_csv_invalid_number", + "Reference CSV end-node label must be a positive integer.", + path, + line_number); + } + end_node_label = parsed_end_node; + } + + std::vector values; + values.reserve(components.size()); + for (const std::string_view component : components) { + double value = 0.0; + if (!parse_finite_double( + fields[column_index(column_indices, component)], + value)) { + return validation_failure( + "validation.reference_csv_invalid_number", + "Reference CSV component '" + std::string{component} + + "' must be a finite number.", + path, + line_number); + } + values.push_back(value); + } + + PositionKey key{instance_name, entity_label, end_node_label}; + if (!positions.insert(key).second) { + return validation_failure( + "validation.reference_csv_duplicate_row", + "Reference CSV contains a duplicate result position.", + path, + line_number); + } + rows.push_back({ + quantity, + {std::move(instance_name), entity_label, end_node_label}, + std::move(values), + }); + } + if (input.bad()) { + return failure( + DiagnosticStage::io, + "validation.reference_csv_read_failed", + "Failed while reading reference CSV file.", + path, + line_number); + } + if (rows.empty()) { + return validation_failure( + "validation.reference_csv_missing_rows", + "Reference CSV contains no result rows.", + path, + line_number); + } + return {std::move(rows), {}}; +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 471cca5..56f1971 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -746,6 +746,7 @@ set_property( add_executable(fesa_validation_comparison_tests unit/validation/comparison_test.cpp + unit/validation/reference_csv_test.cpp ) target_compile_features( @@ -754,6 +755,11 @@ target_compile_features( target_compile_options( fesa_validation_comparison_tests PRIVATE /W4 /permissive- /EHsc ) +target_compile_definitions( + fesa_validation_comparison_tests + PRIVATE + FESA_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) target_link_libraries( fesa_validation_comparison_tests PRIVATE @@ -772,3 +778,21 @@ add_test( COMMAND "$" --gtest_filter=EntityMatching.* ) + +add_test( + NAME ReferenceCsv + COMMAND "$" + --gtest_filter=ReferenceCsv.* +) + +add_test( + NAME InternalForceCsv + COMMAND "$" + --gtest_filter=InternalForceCsv.* +) + +add_test( + NAME StressCsv + COMMAND "$" + --gtest_filter=StressCsv.* +) diff --git a/tests/fixtures/reference/internalforces.csv b/tests/fixtures/reference/internalforces.csv new file mode 100644 index 0000000..712cb44 --- /dev/null +++ b/tests/fixtures/reference/internalforces.csv @@ -0,0 +1,3 @@ +Part Instance Name, Element Label, Node Label, SF-SF1, SF-SF2, SF-SF3, SM-SM1, SM-SM2, SM-SM3 + Part-1-1 , 501 , 101 , 1.25 , -2.5 , 3.75 , -4.0 , 5.5 , -6.25 + Part-1-1 , 501 , 102 , 7.0 , 8.0 , 9.0 , 10.0 , 11.0 , 12.0 diff --git a/tests/fixtures/reference/stresses.csv b/tests/fixtures/reference/stresses.csv new file mode 100644 index 0000000..015b458 --- /dev/null +++ b/tests/fixtures/reference/stresses.csv @@ -0,0 +1,3 @@ +Element Label, Node Label, Sxx +501, 101, 42.5 +501, 102, -17.25 diff --git a/tests/unit/validation/reference_csv_test.cpp b/tests/unit/validation/reference_csv_test.cpp new file mode 100644 index 0000000..6b896cb --- /dev/null +++ b/tests/unit/validation/reference_csv_test.cpp @@ -0,0 +1,235 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +class TemporaryCsv final { +public: + TemporaryCsv(std::string_view name, std::string_view contents) + : path_{std::filesystem::path{testing::TempDir()} / name} { + std::ofstream output{path_, std::ios::binary}; + output.write( + contents.data(), static_cast(contents.size())); + if (!output) { + throw std::runtime_error{"Failed to write temporary reference CSV."}; + } + } + + ~TemporaryCsv() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + TemporaryCsv(const TemporaryCsv&) = delete; + TemporaryCsv& operator=(const TemporaryCsv&) = delete; + + [[nodiscard]] const std::filesystem::path& path() const noexcept { + return path_; + } + +private: + std::filesystem::path path_; +}; + +std::filesystem::path fixture_path(const std::string_view name) { + return std::filesystem::path{FESA_TEST_SOURCE_DIR} / "fixtures" / + "reference" / name; +} + +std::filesystem::path supplied_reference_path(const std::string_view name) { + return std::filesystem::path{FESA_TEST_SOURCE_DIR}.parent_path() / + "reference" / "cantilever beam" / name; +} + +bool has_diagnostic( + const std::vector& diagnostics, + const std::string_view code) { + return std::ranges::any_of( + diagnostics, + [code](const fesa::Diagnostic& diagnostic) { + return diagnostic.code == code; + }); +} + +TEST(ReferenceCsv, ReadsSuppliedDisplacementsWithWhitespaceHeader) { + const auto result = fesa::read_reference_csv( + fesa::ReferenceQuantity::displacement, + supplied_reference_path("cantilever beam displacements.csv"), + "Part-1-1"); + + ASSERT_TRUE(result.diagnostics.empty()); + ASSERT_EQ(result.rows.size(), 11U); + EXPECT_EQ( + result.rows.front().quantity, + fesa::ReferenceQuantity::displacement); + EXPECT_EQ(result.rows.front().position.instance_name, "Part-1-1"); + EXPECT_EQ(result.rows.front().position.entity_label, 1); + EXPECT_FALSE(result.rows.front().position.end_node_label.has_value()); + EXPECT_EQ( + result.rows.front().values, + (std::vector{0.0, 0.0, -1.0e-32, 0.0, 1.0e-31, 0.0})); + EXPECT_EQ(result.rows.back().position.entity_label, 11); + EXPECT_EQ(result.rows.back().values[2], -1.92e-4); +} + +TEST(ReferenceCsv, ReadsSuppliedReactionsWithWhitespaceHeader) { + const auto result = fesa::read_reference_csv( + fesa::ReferenceQuantity::reaction, + supplied_reference_path("cantilever beam reactions.csv"), + "Part-1-1"); + + ASSERT_TRUE(result.diagnostics.empty()); + ASSERT_EQ(result.rows.size(), 11U); + EXPECT_EQ(result.rows.front().position.instance_name, "Part-1-1"); + EXPECT_EQ(result.rows.front().position.entity_label, 1); + EXPECT_FALSE(result.rows.front().position.end_node_label.has_value()); + EXPECT_EQ( + result.rows.front().values, + (std::vector{0.0, 0.0, 1.0e4, 0.0, -1.0e5, 0.0})); +} + +TEST(InternalForceCsv, MapsAllSixComponentsAndElementEndPosition) { + const auto result = fesa::read_reference_csv( + fesa::ReferenceQuantity::internal_force, + fixture_path("internalforces.csv"), + "unused-request-name"); + + ASSERT_TRUE(result.diagnostics.empty()); + ASSERT_EQ(result.rows.size(), 2U); + EXPECT_EQ( + result.rows.front().quantity, + fesa::ReferenceQuantity::internal_force); + EXPECT_EQ(result.rows.front().position.instance_name, "Part-1-1"); + EXPECT_EQ(result.rows.front().position.entity_label, 501); + ASSERT_TRUE(result.rows.front().position.end_node_label.has_value()); + EXPECT_EQ(*result.rows.front().position.end_node_label, 101); + EXPECT_EQ( + result.rows.front().values, + (std::vector{1.25, -2.5, 3.75, -4.0, 5.5, -6.25})); +} + +TEST(StressCsv, FillsOmittedInstanceAndReadsCentroidStress) { + const auto result = fesa::read_reference_csv( + fesa::ReferenceQuantity::centroid_stress, + fixture_path("stresses.csv"), + "Part-1-1"); + + ASSERT_TRUE(result.diagnostics.empty()); + ASSERT_EQ(result.rows.size(), 2U); + EXPECT_EQ(result.rows.front().position.instance_name, "Part-1-1"); + EXPECT_EQ(result.rows.front().position.entity_label, 501); + ASSERT_TRUE(result.rows.front().position.end_node_label.has_value()); + EXPECT_EQ(*result.rows.front().position.end_node_label, 101); + EXPECT_EQ(result.rows.front().values, (std::vector{42.5})); + EXPECT_EQ(result.rows.back().values, (std::vector{-17.25})); +} + +TEST(ReferenceCsv, AcceptsUtf8BomOnlyAtTheStartAndTrimsValues) { + const TemporaryCsv input{ + "fesa-reference-bom.csv", + "\xEF\xBB\xBF Part Instance Name , Node Label , U-U1 , U-U2 , " + "U-U3 , UR-UR1 , UR-UR2 , UR-UR3\n" + " Beam-1 , 7 , 1 , 2 , 3 , 4 , 5 , 6 \n"}; + + const auto result = fesa::read_reference_csv( + fesa::ReferenceQuantity::displacement, input.path(), "unused"); + + ASSERT_TRUE(result.diagnostics.empty()); + ASSERT_EQ(result.rows.size(), 1U); + EXPECT_EQ(result.rows[0].position.instance_name, "Beam-1"); + EXPECT_EQ(result.rows[0].position.entity_label, 7); + EXPECT_EQ( + result.rows[0].values, + (std::vector{1.0, 2.0, 3.0, 4.0, 5.0, 6.0})); +} + +TEST(ReferenceCsv, DiagnosesMissingRequiredColumn) { + const TemporaryCsv input{ + "fesa-reference-missing-column.csv", + "Node Label,U-U1,U-U2,U-U3,UR-UR1,UR-UR2,Unexpected\n" + "1,0,0,0,0,0,0\n"}; + + const auto result = fesa::read_reference_csv( + fesa::ReferenceQuantity::displacement, input.path(), "Part-1-1"); + + EXPECT_TRUE(result.rows.empty()); + ASSERT_TRUE(has_diagnostic( + result.diagnostics, "validation.reference_csv_missing_column")); + ASSERT_TRUE(result.diagnostics.front().source.has_value()); + EXPECT_EQ(result.diagnostics.front().source->line, 1U); +} + +TEST(ReferenceCsv, DiagnosesDuplicateResultPosition) { + const TemporaryCsv input{ + "fesa-reference-duplicate.csv", + "Element Label,Node Label,Sxx\n" + "8,2,10\n" + "8,2,11\n"}; + + const auto result = fesa::read_reference_csv( + fesa::ReferenceQuantity::centroid_stress, + input.path(), + "Part-1-1"); + + EXPECT_TRUE(result.rows.empty()); + ASSERT_TRUE(has_diagnostic( + result.diagnostics, "validation.reference_csv_duplicate_row")); + ASSERT_TRUE(result.diagnostics.front().source.has_value()); + EXPECT_EQ(result.diagnostics.front().source->line, 3U); +} + +TEST(ReferenceCsv, DiagnosesInvalidAndNonfiniteNumbers) { + const TemporaryCsv invalid{ + "fesa-reference-invalid-number.csv", + "Node Label,RF-RF1,RF-RF2,RF-RF3,RM-RM1,RM-RM2,RM-RM3\n" + "1,0,0,not-a-number,0,0,0\n"}; + const TemporaryCsv nonfinite{ + "fesa-reference-nonfinite-number.csv", + "Element Label,Node Label,Sxx\n" + "8,2,nan\n"}; + + const auto invalid_result = fesa::read_reference_csv( + fesa::ReferenceQuantity::reaction, + invalid.path(), + "Part-1-1"); + const auto nonfinite_result = fesa::read_reference_csv( + fesa::ReferenceQuantity::centroid_stress, + nonfinite.path(), + "Part-1-1"); + + EXPECT_TRUE(invalid_result.rows.empty()); + EXPECT_TRUE(nonfinite_result.rows.empty()); + EXPECT_TRUE(has_diagnostic( + invalid_result.diagnostics, + "validation.reference_csv_invalid_number")); + EXPECT_TRUE(has_diagnostic( + nonfinite_result.diagnostics, + "validation.reference_csv_invalid_number")); +} + +TEST(ReferenceCsv, RejectsUtf8BomOutsideTheFileStart) { + const TemporaryCsv input{ + "fesa-reference-misplaced-bom.csv", + "Node Label,\xEF\xBB\xBF U-U1,U-U2,U-U3,UR-UR1,UR-UR2,UR-UR3\n" + "1,0,0,0,0,0,0\n"}; + + const auto result = fesa::read_reference_csv( + fesa::ReferenceQuantity::displacement, input.path(), "Part-1-1"); + + EXPECT_TRUE(result.rows.empty()); + EXPECT_TRUE(has_diagnostic( + result.diagnostics, + "validation.reference_csv_invalid_encoding")); +} + +} // namespace