diff --git a/CMakeLists.txt b/CMakeLists.txt index d6f23e0..aa251cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,7 @@ include(cmake/FesaDependencies.cmake) add_library(fesa_core STATIC src/fesa/analysis/linear_static_analysis.cpp + src/fesa/analysis/run_solver.cpp src/fesa/assembly/serial_assembler.cpp src/fesa/constraints/essential_bc.cpp src/fesa/core/version.cpp diff --git a/include/fesa/analysis/run_solver.hpp b/include/fesa/analysis/run_solver.hpp new file mode 100644 index 0000000..50b27d6 --- /dev/null +++ b/include/fesa/analysis/run_solver.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include + +namespace fesa { + +struct AnalysisRequest final { + std::filesystem::path input_path; + std::filesystem::path output_path; +}; + +[[nodiscard]] AnalysisRunResult run_solver( + const AnalysisRequest& request); + +} // namespace fesa diff --git a/src/fesa/analysis/run_solver.cpp b/src/fesa/analysis/run_solver.cpp new file mode 100644 index 0000000..2a8a859 --- /dev/null +++ b/src/fesa/analysis/run_solver.cpp @@ -0,0 +1,37 @@ +#include + +#include +#include + +#include +#include +#include + +namespace fesa { + +AnalysisRunResult run_solver(const AnalysisRequest& request) { + ParseDeckResult parsed = parse_deck(request.input_path); + if (!parsed.deck.has_value()) { + return {false, std::nullopt, std::move(parsed.diagnostics)}; + } + + DomainBuildResult mapped = map_deck_to_domain(*parsed.deck); + if (!mapped.domain.has_value()) { + return {false, std::nullopt, std::move(mapped.diagnostics)}; + } + + AnalysisRunResult run = LinearStaticAnalysis{}.run(*mapped.domain); + if (!run.succeeded || !run.results.has_value()) { + return run; + } + + std::vector write_diagnostics = write_hdf5( + request.output_path, *mapped.domain, *run.results); + if (!write_diagnostics.empty()) { + return {false, std::nullopt, std::move(write_diagnostics)}; + } + + return run; +} + +} // namespace fesa diff --git a/src/fesa/cli/main.cpp b/src/fesa/cli/main.cpp index c506fe2..7b531fd 100644 --- a/src/fesa/cli/main.cpp +++ b/src/fesa/cli/main.cpp @@ -1,14 +1,75 @@ +#include #include +#include #include +#include #include +namespace { + +std::string_view stage_name(const fesa::DiagnosticStage stage) { + switch (stage) { + case fesa::DiagnosticStage::io: + return "io"; + case fesa::DiagnosticStage::syntax: + return "syntax"; + case fesa::DiagnosticStage::semantic: + return "semantic"; + case fesa::DiagnosticStage::model: + return "model"; + case fesa::DiagnosticStage::equation: + return "equation"; + case fesa::DiagnosticStage::solver: + return "solver"; + case fesa::DiagnosticStage::results: + return "results"; + case fesa::DiagnosticStage::validation: + return "validation"; + } + return "unknown"; +} + +void print_diagnostic(const fesa::Diagnostic& diagnostic) { + std::cerr << stage_name(diagnostic.stage) << " [" << diagnostic.code + << "]"; + if (diagnostic.source.has_value()) { + const fesa::SourceLocation& source = *diagnostic.source; + std::cerr << ' ' << source.file.string() << ':' << source.line << ':' + << source.column; + } + std::cerr << ": " << diagnostic.message << '\n'; +} + +void print_usage() { + std::cerr << "Usage:\n" + << " fesa solve --output \n" + << " fesa --version\n"; +} + +} // namespace + int main(int argc, char* argv[]) { if (argc == 2 && std::string_view{argv[1]} == "--version") { std::cout << fesa::version() << '\n'; return 0; } - std::cerr << "Usage: fesa --version\n"; + if (argc == 5 && std::string_view{argv[1]} == "solve" && + std::string_view{argv[3]} == "--output") { + const fesa::AnalysisRunResult result = fesa::run_solver({ + std::filesystem::path{argv[2]}, + std::filesystem::path{argv[4]}, + }); + if (result.succeeded) { + return 0; + } + for (const fesa::Diagnostic& diagnostic : result.diagnostics) { + print_diagnostic(diagnostic); + } + return 1; + } + + print_usage(); return 1; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 920c250..dc1a149 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -488,3 +488,43 @@ set_property( PROPERTY ENVIRONMENT_MODIFICATION ${FESA_DEPENDENCY_RUNTIME_MODIFICATIONS} ) + +add_executable(fesa_pipeline_integration_tests + integration/pipeline/minimal_cantilever_test.cpp +) + +target_compile_features( + fesa_pipeline_integration_tests PRIVATE cxx_std_20 +) +target_compile_options( + fesa_pipeline_integration_tests + PRIVATE + /W4 + /permissive- + /EHsc +) +target_compile_definitions( + fesa_pipeline_integration_tests + PRIVATE + FESA_CLI_PATH="$" + FESA_TEST_BINARY_DIR="${CMAKE_BINARY_DIR}" + FESA_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) +target_link_libraries(fesa_pipeline_integration_tests + PRIVATE + fesa_core + GTest::gtest_main +) +add_dependencies(fesa_pipeline_integration_tests fesa) + +add_test( + NAME MinimalCantileverPipeline + COMMAND "$" + --gtest_filter=MinimalCantileverPipeline.* +) + +set_property( + TEST MinimalCantileverPipeline + PROPERTY ENVIRONMENT_MODIFICATION + ${FESA_DEPENDENCY_RUNTIME_MODIFICATIONS} +) diff --git a/tests/integration/pipeline/minimal_cantilever_test.cpp b/tests/integration/pipeline/minimal_cantilever_test.cpp new file mode 100644 index 0000000..913892e --- /dev/null +++ b/tests/integration/pipeline/minimal_cantilever_test.cpp @@ -0,0 +1,159 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +class TemporaryPath final { +public: + explicit TemporaryPath(std::filesystem::path path) + : path_{std::move(path)} { + std::error_code error; + std::filesystem::create_directories(path_.parent_path(), error); + if (error) { + throw std::runtime_error{"Failed to create pipeline test directory."}; + } + std::filesystem::remove(path_, error); + } + + ~TemporaryPath() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + TemporaryPath(const TemporaryPath&) = delete; + TemporaryPath& operator=(const TemporaryPath&) = 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" / + "abaqus" / name; +} + +std::filesystem::path test_output_path(const std::string_view name) { + return std::filesystem::path{FESA_TEST_BINARY_DIR} / "testing" / name; +} + +void write_text( + const std::filesystem::path& path, + const std::string_view contents) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream output{path, std::ios::binary}; + output.write( + contents.data(), static_cast(contents.size())); + if (!output) { + throw std::runtime_error{"Failed to write pipeline test input."}; + } +} + +std::string quote(const std::filesystem::path& path) { + return '"' + path.string() + '"'; +} + +std::string read_text(const std::filesystem::path& path) { + std::ifstream input{path, std::ios::binary}; + return { + std::istreambuf_iterator{input}, + std::istreambuf_iterator{}, + }; +} + +TEST(MinimalCantileverPipeline, WritesReadableFiniteEquilibratedResults) { + const TemporaryPath output{ + test_output_path("minimal-cantilever-pipeline.h5")}; + + const fesa::AnalysisRunResult run = fesa::run_solver({ + fixture_path("minimal_cantilever.inp"), + output.path(), + }); + + ASSERT_TRUE(run.succeeded); + EXPECT_TRUE(run.diagnostics.empty()); + + const fesa::Hdf5ReadResult read = + fesa::read_hdf5_results(output.path()); + ASSERT_TRUE(read.database.has_value()); + ASSERT_TRUE(read.model.has_value()); + EXPECT_TRUE(read.diagnostics.empty()); + EXPECT_EQ(read.database->schema_version, "1.0.0"); + + ASSERT_EQ(read.model->nodes.size(), 2U); + EXPECT_EQ(read.model->nodes[0].id, fesa::NodeId{0}); + EXPECT_EQ(read.model->nodes[1].id, fesa::NodeId{1}); + + ASSERT_EQ(read.database->steps.size(), 1U); + ASSERT_EQ(read.database->steps[0].frames.size(), 1U); + const fesa::NodalFrame& nodal = + read.database->steps[0].frames[0].nodal; + ASSERT_EQ(nodal.node_ids.size(), 2U); + ASSERT_EQ(nodal.displacement.size(), 2U); + ASSERT_EQ(nodal.reaction.size(), 2U); + EXPECT_EQ(nodal.node_ids[0], fesa::NodeId{0}); + EXPECT_EQ(nodal.node_ids[1], fesa::NodeId{1}); + + for (const auto& values : nodal.displacement) { + for (const double value : values) { + EXPECT_TRUE(std::isfinite(value)); + } + } + for (const auto& values : nodal.reaction) { + for (const double value : values) { + EXPECT_TRUE(std::isfinite(value)); + } + } + + const double total_y_reaction = + nodal.reaction[0][1] + nodal.reaction[1][1]; + EXPECT_NEAR(total_y_reaction - 1.0, 0.0, 1.0e-12); + EXPECT_LT(nodal.displacement[1][1], 0.0); +} + +TEST(MinimalCantileverPipeline, CliPreservesFailureSourceDiagnostic) { + const TemporaryPath input{ + test_output_path("minimal-cantilever-invalid.inp")}; + const TemporaryPath output{ + test_output_path("minimal-cantilever-invalid.h5")}; + const TemporaryPath error_output{ + test_output_path("minimal-cantilever-invalid.stderr.txt")}; + write_text( + input.path(), + "*NODE\n" + "*UNSUPPORTED\n"); + + const std::string command = + '"' + quote(std::filesystem::path{FESA_CLI_PATH}) + " solve " + + quote(input.path()) + " --output " + quote(output.path()) + + " 1>NUL 2>" + quote(error_output.path()) + '"'; + const int exit_code = std::system(command.c_str()); + + EXPECT_NE(exit_code, 0); + EXPECT_FALSE(std::filesystem::exists(output.path())); + const std::string diagnostic = read_text(error_output.path()); + EXPECT_NE(diagnostic.find("syntax"), std::string::npos); + EXPECT_NE( + diagnostic.find("abaqus.unsupported_keyword"), + std::string::npos); + EXPECT_NE(diagnostic.find(input.path().string()), std::string::npos); + EXPECT_NE(diagnostic.find(":2:1"), std::string::npos); +} + +} // namespace