From 7cccfcdde85733e86983f2ce189a19085a2fe98b Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 22:23:14 +0900 Subject: [PATCH] feat(thermal): connect transient heat application execution --- include/fesa/analysis/prepared_analysis.h | 15 +- src/fesa/analysis/prepared_analysis.cpp | 39 +- src/fesa/app/fesa_application.cpp | 8 +- .../app/thermal_application_test.cpp | 491 ++++++++++++++++++ 4 files changed, 546 insertions(+), 7 deletions(-) create mode 100644 tests/integration/app/thermal_application_test.cpp diff --git a/include/fesa/analysis/prepared_analysis.h b/include/fesa/analysis/prepared_analysis.h index e623fde..1c7a7bc 100644 --- a/include/fesa/analysis/prepared_analysis.h +++ b/include/fesa/analysis/prepared_analysis.h @@ -9,6 +9,7 @@ #include "fesa/analysis/dynamic_analysis_state.h" #include "fesa/analysis/eigen_analysis_state.h" #include "fesa/analysis/nonlinear_analysis_state.h" +#include "fesa/analysis/transient_heat_procedure.h" #include "fesa/core/diagnostic.h" #include "fesa/core/status.h" #include "fesa/model/domain.h" @@ -24,6 +25,7 @@ class NonlinearStaticProcedureExecutor; class ResultsWriter; class HhtDynamicProcedureExecutor; class DynamicResultsWriter; +class ThermalResultsWriter; struct DerivedOutputPaths; /// @brief Owns one parsed and semantically mapped analysis input. @@ -60,27 +62,34 @@ struct DynamicExecutionResult { using PreparedAnalysisOutcome = std::variant; + EigenAnalysisState, DynamicExecutionResult, + ThermalExecutionResult>; /// @brief Selects the approved procedure from a prepared Domain. /// @pre The executors and log outlive this call. +/// @param thermal_executor Required only for a thermal Domain, whose structural +/// step collection is empty; absence returns a solver failure. /// @return A validated procedure outcome without opening a result file. Result ExecutePreparedAnalysis( const Domain& domain, LinearStaticProcedureExecutor& linear_executor, NonlinearStaticProcedureExecutor& nonlinear_executor, const LinearFrequencyProcedureExecutor& frequency_executor, NewtonIterationLogWriter& log, - const HhtDynamicProcedureExecutor* dynamic_executor = nullptr); + const HhtDynamicProcedureExecutor* dynamic_executor = nullptr, + const TransientHeatProcedureExecutor* thermal_executor = nullptr); /// @brief Writes an already computed outcome to its authoritative result file. /// @pre The Newton log has closed successfully before this function is called. +/// @param thermal_writer Required only for a thermal outcome; absence returns +/// an output failure without opening the final result. /// @return Success after the selected writer atomically finalizes its result. Status FinalizePreparedAnalysisResult( const DerivedOutputPaths& paths, const Domain& domain, const PreparedAnalysisOutcome& outcome, const std::vector& diagnostics, ResultsWriter& linear_writer, NonlinearResultsWriter& nonlinear_writer, EigenResultsWriter& eigen_writer, - DynamicResultsWriter* dynamic_writer = nullptr); + DynamicResultsWriter* dynamic_writer = nullptr, + ThermalResultsWriter* thermal_writer = nullptr); } // namespace fesa diff --git a/src/fesa/analysis/prepared_analysis.cpp b/src/fesa/analysis/prepared_analysis.cpp index 597e10a..7ea59d5 100644 --- a/src/fesa/analysis/prepared_analysis.cpp +++ b/src/fesa/analysis/prepared_analysis.cpp @@ -14,6 +14,7 @@ #include "fesa/results/eigen_results_writer.h" #include "fesa/results/nonlinear_results_writer.h" #include "fesa/results/results_writer.h" +#include "fesa/results/thermal_results_writer.h" namespace fesa { @@ -40,7 +41,26 @@ Result ExecutePreparedAnalysis( NonlinearStaticProcedureExecutor& nonlinear_executor, const LinearFrequencyProcedureExecutor& frequency_executor, NewtonIterationLogWriter& log, - const HhtDynamicProcedureExecutor* dynamic_executor) { + const HhtDynamicProcedureExecutor* dynamic_executor, + const TransientHeatProcedureExecutor* thermal_executor) { + if (domain.ThermalStep() != nullptr) { + if (thermal_executor == nullptr) { + return Result::Failure( + Status::Failure(FailureCategory::kSolver, + {{Severity::kError, + "missing-thermal-executor", + {}, + "HEAT TRANSFER", + {}, + "Thermal execution adapter is required."}})); + } + auto thermal = thermal_executor->Execute(domain); + if (!thermal.HasValue()) { + return Result::Failure(thermal.GetStatus()); + } + return Result::Success( + PreparedAnalysisOutcome{std::move(thermal.Value())}); + } if (domain.Steps().Size() != 1U) { return Result::Failure(Status::Failure( FailureCategory::kInput, @@ -100,7 +120,22 @@ Status FinalizePreparedAnalysisResult( const PreparedAnalysisOutcome& outcome, const std::vector& diagnostics, ResultsWriter& linear_writer, NonlinearResultsWriter& nonlinear_writer, EigenResultsWriter& eigen_writer, - DynamicResultsWriter* dynamic_writer) { + DynamicResultsWriter* dynamic_writer, + ThermalResultsWriter* thermal_writer) { + if (std::holds_alternative(outcome)) { + if (thermal_writer == nullptr) { + return Status::Failure(FailureCategory::kOutput, + {{Severity::kError, + "missing-thermal-writer", + {}, + "HEAT TRANSFER", + {}, + "Thermal result writer is required."}}); + } + return thermal_writer->Write( + paths.results_path, domain, + std::get(outcome).state, diagnostics); + } if (std::holds_alternative(outcome)) { if (dynamic_writer == nullptr) return Status::Failure(FailureCategory::kOutput, diff --git a/src/fesa/app/fesa_application.cpp b/src/fesa/app/fesa_application.cpp index c80204a..5532552 100644 --- a/src/fesa/app/fesa_application.cpp +++ b/src/fesa/app/fesa_application.cpp @@ -18,6 +18,7 @@ #include "fesa/io/hdf5/eigen_hdf5_results_writer.h" #include "fesa/io/hdf5/hdf5_results_writer.h" #include "fesa/io/hdf5/nonlinear_hdf5_results_writer.h" +#include "fesa/io/hdf5/thermal_hdf5_results_writer.h" #include "fesa/solvers/linear/mkl_pardiso_solver.h" #include "fesa/solvers/linear/mkl_pardiso_tangent_solver.h" @@ -87,6 +88,7 @@ int FesaApplication::Run(const std::vector& arguments) { NonlinearHdf5ResultsWriter nonlinear_writer; EigenHdf5ResultsWriter eigen_writer; DynamicHdf5ResultsWriter dynamic_writer; + ThermalHdf5ResultsWriter thermal_writer; NewtonIterationLogWriter log; Status status = log.Open(paths.Value().log_path); @@ -106,9 +108,11 @@ int FesaApplication::Run(const std::vector& arguments) { const LinearFrequencyProcedureExecutor frequency_executor; const HhtDynamicProcedureExecutor dynamic_executor( parallel_for, linear_solver, tangent_solver); + const TransientHeatProcedureExecutor thermal_executor(parallel_for, + linear_solver); Result outcome = ExecutePreparedAnalysis( prepared.Value().domain, linear_executor, nonlinear_executor, - frequency_executor, log, &dynamic_executor); + frequency_executor, log, &dynamic_executor, &thermal_executor); if (!outcome.HasValue()) { status = outcome.GetStatus(); } else { @@ -120,7 +124,7 @@ int FesaApplication::Run(const std::vector& arguments) { status = FinalizePreparedAnalysisResult( paths.Value(), prepared.Value().domain, outcome.Value(), prepared.Value().diagnostics, linear_writer, nonlinear_writer, - eigen_writer, &dynamic_writer); + eigen_writer, &dynamic_writer, &thermal_writer); if (status.IsOk()) { return kSuccessExitCode; } diff --git a/tests/integration/app/thermal_application_test.cpp b/tests/integration/app/thermal_application_test.cpp new file mode 100644 index 0000000..b97b05a --- /dev/null +++ b/tests/integration/app/thermal_application_test.cpp @@ -0,0 +1,491 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/app/fesa_application.h" + +#define NOMINMAX +#include + +#include "fesa/analysis/linear_frequency_procedure.h" +#include "fesa/analysis/linear_static_procedure.h" +#include "fesa/analysis/newton_iteration_log.h" +#include "fesa/analysis/nonlinear_static_procedure.h" +#include "fesa/analysis/prepared_analysis.h" +#include "fesa/app/output_paths.h" +#include "fesa/assembly/parallel_for.h" +#include "fesa/io/hdf5/eigen_hdf5_results_writer.h" +#include "fesa/io/hdf5/hdf5_results_writer.h" +#include "fesa/io/hdf5/nonlinear_hdf5_results_writer.h" +#include "fesa/solvers/linear/mkl_pardiso_solver.h" +#include "fesa/solvers/linear/mkl_pardiso_tangent_solver.h" + +namespace { + +class HdfHandle { + public: + HdfHandle(hid_t value, herr_t (*close)(hid_t)) + : value_(value), close_(close) { + if (value < 0) throw std::runtime_error{"Cannot open HDF test object."}; + } + ~HdfHandle() { close_(value_); } + HdfHandle(const HdfHandle&) = delete; + HdfHandle& operator=(const HdfHandle&) = delete; + hid_t Get() const { return value_; } + + private: + hid_t value_; + herr_t (*close_)(hid_t); +}; + +std::vector Shape(hid_t file, const std::string& path) { + HdfHandle data{H5Dopen2(file, path.c_str(), H5P_DEFAULT), H5Dclose}; + HdfHandle space{H5Dget_space(data.Get()), H5Sclose}; + const int rank = H5Sget_simple_extent_ndims(space.Get()); + if (rank < 0) throw std::runtime_error{"Invalid HDF test shape."}; + std::vector shape(static_cast(rank)); + EXPECT_GE(H5Sget_simple_extent_dims(space.Get(), shape.data(), nullptr), 0); + return shape; +} + +std::vector Doubles(hid_t file, const std::string& path) { + std::size_t count = 1; + for (auto extent : Shape(file, path)) + count *= static_cast(extent); + std::vector result(count); + HdfHandle data{H5Dopen2(file, path.c_str(), H5P_DEFAULT), H5Dclose}; + EXPECT_GE(H5Dread(data.Get(), H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL, + H5P_DEFAULT, result.data()), + 0); + return result; +} + +std::string StringAttribute(hid_t object, const char* name) { + HdfHandle attribute{H5Aopen(object, name, H5P_DEFAULT), H5Aclose}; + HdfHandle type{H5Aget_type(attribute.Get()), H5Tclose}; + char* value = nullptr; + EXPECT_GE(H5Aread(attribute.Get(), type.Get(), &value), 0); + const std::string result = value == nullptr ? "" : value; + if (value != nullptr) H5free_memory(value); + return result; +} + +struct FrameRow { + std::uint64_t frame_index; + std::uint64_t increment_index; + double time; + double time_increment; + std::uint8_t is_initial; +}; + +std::vector Frames(hid_t file) { + const std::string path = "/steps/Step-1/accepted_frames"; + const auto shape = Shape(file, path); + if (shape.size() != 1) throw std::runtime_error{"Invalid frame shape."}; + std::vector frames(static_cast(shape[0])); + HdfHandle type{H5Tcreate(H5T_COMPOUND, sizeof(FrameRow)), H5Tclose}; + EXPECT_GE(H5Tinsert(type.Get(), "frame_index", HOFFSET(FrameRow, frame_index), + H5T_NATIVE_UINT64), + 0); + EXPECT_GE(H5Tinsert(type.Get(), "increment_index", + HOFFSET(FrameRow, increment_index), H5T_NATIVE_UINT64), + 0); + EXPECT_GE( + H5Tinsert(type.Get(), "time", HOFFSET(FrameRow, time), H5T_NATIVE_DOUBLE), + 0); + EXPECT_GE(H5Tinsert(type.Get(), "time_increment", + HOFFSET(FrameRow, time_increment), H5T_NATIVE_DOUBLE), + 0); + EXPECT_GE(H5Tinsert(type.Get(), "is_initial", HOFFSET(FrameRow, is_initial), + H5T_NATIVE_UINT8), + 0); + HdfHandle data{H5Dopen2(file, path.c_str(), H5P_DEFAULT), H5Dclose}; + EXPECT_GE(H5Dread(data.Get(), type.Get(), H5S_ALL, H5S_ALL, H5P_DEFAULT, + frames.data()), + 0); + return frames; +} + +std::string ReadBytes(const std::filesystem::path& path) { + std::ifstream input{path, std::ios::binary}; + return {std::istreambuf_iterator{input}, + std::istreambuf_iterator{}}; +} + +std::string Replace(std::string text, const std::string& from, + const std::string& to) { + const auto position = text.find(from); + if (position == std::string::npos) + throw std::runtime_error{"Missing fixture text."}; + text.replace(position, from.size(), to); + return text; +} + +struct AppRun { + int exit_code; + std::string error; +}; +AppRun RunApplication(const std::filesystem::path& input) { + testing::internal::CaptureStderr(); + const int result = fesa::FesaApplication{}.Run({input.string()}); + return {result, testing::internal::GetCapturedStderr()}; +} + +class ThermalApplicationTest : public testing::Test { + protected: + void SetUp() override { + static std::atomic counter{0}; + do { + directory_ = + std::filesystem::temp_directory_path() / + ("fesa-thermal-app-" + std::to_string(std::random_device{}()) + "-" + + std::to_string(counter.fetch_add(1))); + } while (!std::filesystem::create_directory(directory_)); + } + void TearDown() override { + std::error_code error; + std::filesystem::remove_all(directory_, error); + } + std::filesystem::path Path(const std::string& name) const { + return directory_ / name; + } + void Write(const std::string& name, const std::string& text) { + std::ofstream output{Path(name), std::ios::binary}; + output << text; + ASSERT_TRUE(output.good()); + } + std::filesystem::path directory_; +}; + +std::string Deck(const std::string& controls = ".3,.7", + const std::string& initial = "-2") { + return R"inp(*Part,name=P +*Node +1,0,0,0 +2,1,0,0 +3,1,1,0 +4,0,1,0 +*Element,type=DS4 +1,1,2,3,4 +*Elset,elset=E +1 +*Shell Section,elset=E,material=M +1,5 +*End Part +*Assembly,name=A +*Instance,name=I,part=P +*End Instance +*Nset,nset=All,instance=I +1,2,3,4 +*End Assembly +*Material,name=M +*Density +1 +*Conductivity +1 +*Specific Heat +1 +*Initial Conditions,type=TEMPERATURE +All,)inp" + + initial + R"inp( +*Step,name=Raw Heat,inc=10 +*Heat Transfer +)inp" + controls + + R"inp( +*Output,field,variable=PRESELECT +*End Step +)inp"; +} + +TEST_F(ThermalApplicationTest, ExecutesThermalInputThroughAtomicOutput) { + Write("Heat.Model.INP", Deck()); + const auto run = RunApplication(Path("Heat.Model.INP")); + ASSERT_EQ(run.exit_code, 0) << run.error; + EXPECT_GT(H5Fis_hdf5(Path("Heat.Model_results.h5").string().c_str()), 0); + EXPECT_EQ(std::filesystem::file_size(Path("Heat.Model_log.txt")), 0U); + HdfHandle file{H5Fopen(Path("Heat.Model_results.h5").string().c_str(), + H5F_ACC_RDONLY, H5P_DEFAULT), + H5Fclose}; + HdfHandle metadata{H5Gopen2(file.Get(), "/metadata", H5P_DEFAULT), H5Gclose}; + EXPECT_EQ(StringAttribute(metadata.Get(), "source_step_name"), "Raw Heat"); + EXPECT_EQ(StringAttribute(metadata.Get(), "feature_id"), + "shell-transient-heat-transfer"); + EXPECT_EQ(StringAttribute(metadata.Get(), "analysis_procedure"), + "uncoupled-transient-heat-transfer"); + EXPECT_EQ(StringAttribute(metadata.Get(), "source_input_identity") + .find("path=" + Path("Heat.Model.INP").generic_u8string() + + ";content_identity="), + 0U); + const auto frames = Frames(file.Get()); + ASSERT_EQ(frames.size(), 4U); + for (std::size_t index = 0; index < frames.size(); ++index) { + const auto& frame = frames[index]; + EXPECT_EQ(frame.frame_index, index); + EXPECT_EQ(frame.increment_index, index); + EXPECT_EQ(frame.is_initial, index == 0 ? 1 : 0); + EXPECT_DOUBLE_EQ(frame.time, + index == 3 ? .7 : static_cast(index) * .3); + EXPECT_DOUBLE_EQ(frame.time_increment, index == 0 ? 0 + : index == 3 ? std::fma(-2., .3, .7) + : .3); + const auto root = "/steps/Step-1/frames/" + std::to_string(index); + EXPECT_EQ(Shape(file.Get(), root + "/nodal/temperature"), + (std::vector{4, 5})); + for (double value : Doubles(file.Get(), root + "/nodal/temperature")) { + EXPECT_TRUE(std::isfinite(value)); + EXPECT_LT(value, 0.); + if (index == 0) EXPECT_DOUBLE_EQ(value, -2.); + } + if (index == 0) { + EXPECT_EQ( + H5Lexists(file.Get(), (root + "/nodal/reaction_heat_rate").c_str(), + H5P_DEFAULT), + 0); + EXPECT_EQ(H5Lexists(file.Get(), (root + "/element").c_str(), H5P_DEFAULT), + 0); + } else { + EXPECT_EQ(Shape(file.Get(), root + "/nodal/reaction_heat_rate"), + (std::vector{0})); + EXPECT_EQ(Shape(file.Get(), root + "/element/heat_flux"), + (std::vector{1, 4, 6, 3})); + } + } + EXPECT_EQ(Shape(file.Get(), "/diagnostics"), (std::vector{1})); + HdfHandle diagnostics{H5Dopen2(file.Get(), "/diagnostics", H5P_DEFAULT), + H5Dclose}; + HdfHandle string_type{H5Tcopy(H5T_C_S1), H5Tclose}; + ASSERT_GE(H5Tset_size(string_type.Get(), H5T_VARIABLE), 0); + ASSERT_GE(H5Tset_cset(string_type.Get(), H5T_CSET_UTF8), 0); + HdfHandle row_type{H5Tcreate(H5T_COMPOUND, sizeof(char*)), H5Tclose}; + ASSERT_GE(H5Tinsert(row_type.Get(), "code", 0, string_type.Get()), 0); + char* code = nullptr; + ASSERT_GE(H5Dread(diagnostics.Get(), row_type.Get(), H5S_ALL, H5S_ALL, + H5P_DEFAULT, &code), + 0); + EXPECT_STREQ(code, "ignored-input-keyword"); + H5free_memory(code); +} + +TEST_F(ThermalApplicationTest, + EndpointCloseUsesActualDeltaAndLargeIncrementUsesPeriod) { + for (const auto& controls : {".3,.9", "2,.7"}) { + SCOPED_TRACE(controls); + Write("model.inp", Deck(controls, "0")); + const auto run = RunApplication(Path("model.inp")); + ASSERT_EQ(run.exit_code, 0) << run.error; + HdfHandle file{H5Fopen(Path("model_results.h5").string().c_str(), + H5F_ACC_RDONLY, H5P_DEFAULT), + H5Fclose}; + const auto frames = Frames(file.Get()); + if (std::string{controls} == ".3,.9") { + ASSERT_EQ(frames.size(), 4U); + EXPECT_DOUBLE_EQ(frames.back().time, .9); + EXPECT_DOUBLE_EQ(frames.back().time_increment, .3); + EXPECT_NE(frames.back().time_increment, frames[3].time - frames[2].time); + } else { + ASSERT_EQ(frames.size(), 2U); + EXPECT_DOUBLE_EQ(frames.back().time, .7); + EXPECT_DOUBLE_EQ(frames.back().time_increment, .7); + } + } +} + +TEST_F(ThermalApplicationTest, RawInitialPrecedesPrescribedBoundaryProjection) { + auto deck = Replace(Deck(".1,.2"), "*Output,field", + "*Boundary\nAll,11,11,1\nAll,15,15,1\n*Output,field"); + Write("model.inp", deck); + const auto run = RunApplication(Path("model.inp")); + ASSERT_EQ(run.exit_code, 0) << run.error; + HdfHandle file{H5Fopen(Path("model_results.h5").string().c_str(), + H5F_ACC_RDONLY, H5P_DEFAULT), + H5Fclose}; + for (double value : + Doubles(file.Get(), "/steps/Step-1/frames/0/nodal/temperature")) + EXPECT_DOUBLE_EQ(value, -2); + for (int frame = 1; frame <= 2; ++frame) { + const auto root = "/steps/Step-1/frames/" + std::to_string(frame); + const auto temperature = Doubles(file.Get(), root + "/nodal/temperature"); + for (std::size_t node = 0; node < 4; ++node) { + EXPECT_DOUBLE_EQ(temperature[5 * node], 1); + EXPECT_DOUBLE_EQ(temperature[5 * node + 4], 1); + } + EXPECT_EQ(Shape(file.Get(), root + "/nodal/reaction_heat_rate"), + (std::vector{8})); + } +} + +TEST_F(ThermalApplicationTest, InputAndSolverFailuresPreservePreviousFinal) { + const std::string previous{"previous final\0bytes", 20}; + for (const auto& test : std::vector>{ + {Replace(Deck(), "*Output,field", + "*Boundary\nAll,12,12,1\n*Output,field"), + 3}, + {Deck("1.e-300,1.e-300", "1.e200"), 5}}) { + Write("model.inp", test.first); + Write("model_results.h5", previous); + const auto run = RunApplication(Path("model.inp")); + EXPECT_EQ(run.exit_code, test.second) << run.error; + EXPECT_FALSE(run.error.empty()); + EXPECT_EQ(ReadBytes(Path("model_results.h5")), previous); + EXPECT_EQ(std::filesystem::file_size(Path("model_log.txt")), 0U); + } +} + +TEST_F(ThermalApplicationTest, + BusyLeasePrecedesParsingAndPreservesBothOutputs) { + Write("model.inp", "deliberately invalid input"); + Write("model_results.h5", "previous final"); + Write("model_log.txt", "previous log"); + fesa::DerivedOutputPaths paths{Path("model.inp"), Path("model_results.h5"), + Path("model_log.txt")}; + auto lease = fesa::AcquireOutputWriteLease(paths); + ASSERT_TRUE(lease.HasValue()); + const auto run = RunApplication(paths.input_path); + EXPECT_EQ(run.exit_code, 6); + EXPECT_NE(run.error.find("code=output-busy"), std::string::npos); + EXPECT_EQ(ReadBytes(paths.results_path), "previous final"); + EXPECT_EQ(ReadBytes(paths.log_path), "previous log"); +} + +TEST_F(ThermalApplicationTest, + RealFinalizeFailurePreservesFinalAndRemovesCandidate) { + Write("model.inp", Deck(".1,.1", "0")); + Write("model_results.h5", "previous final"); + // Deny replacement of this final while permitting reads. The output lease is + // separate; the candidate must reach the actual atomic finalize operation. + const HANDLE locked = + CreateFileW(Path("model_results.h5").c_str(), GENERIC_READ, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); + ASSERT_NE(locked, INVALID_HANDLE_VALUE); + const auto run = RunApplication(Path("model.inp")); + EXPECT_TRUE(CloseHandle(locked)); + EXPECT_EQ(run.exit_code, 6) << run.error; + EXPECT_EQ(ReadBytes(Path("model_results.h5")), "previous final"); + EXPECT_EQ(std::filesystem::file_size(Path("model_log.txt")), 0U); + for (const auto& entry : std::filesystem::directory_iterator{directory_}) { + EXPECT_EQ(entry.path().filename().string().find(".tmp."), + std::string::npos); + } +} + +TEST_F(ThermalApplicationTest, MissingAdaptersHaveSpecificFailureCategories) { + Write("model.inp", Deck(".1,.1", "0")); + auto prepared = fesa::PrepareAnalysisInput(Path("model.inp")); + ASSERT_TRUE(prepared.HasValue()); + ASSERT_EQ(prepared.Value().domain.Steps().Size(), 0U); + fesa::SerialParallelFor parallel; + fesa::MklPardisoSolver solver; + fesa::MklPardisoTangentSolver tangent; + fesa::LinearStaticProcedureExecutor linear{parallel, solver}; + fesa::NonlinearStaticProcedureExecutor nonlinear{parallel, tangent}; + fesa::LinearFrequencyProcedureExecutor frequency; + fesa::TransientHeatProcedureExecutor thermal{parallel, solver}; + fesa::NewtonIterationLogWriter log; + ASSERT_TRUE(log.Open(Path("model_log.txt")).IsOk()); + auto missing = fesa::ExecutePreparedAnalysis(prepared.Value().domain, linear, + nonlinear, frequency, log); + ASSERT_FALSE(missing.HasValue()); + EXPECT_EQ(missing.GetStatus().Category(), fesa::FailureCategory::kSolver); + ASSERT_EQ(missing.GetStatus().Diagnostics().size(), 1U); + EXPECT_EQ(missing.GetStatus().Diagnostics()[0].code, + "missing-thermal-executor"); + auto outcome = + fesa::ExecutePreparedAnalysis(prepared.Value().domain, linear, nonlinear, + frequency, log, nullptr, &thermal); + ASSERT_TRUE(outcome.HasValue()); + ASSERT_TRUE( + std::holds_alternative(outcome.Value())); + ASSERT_TRUE(log.Close().IsOk()); + fesa::Hdf5ResultsWriter linear_writer; + fesa::NonlinearHdf5ResultsWriter nonlinear_writer; + fesa::EigenHdf5ResultsWriter eigen_writer; + const fesa::DerivedOutputPaths paths{ + Path("model.inp"), Path("model_results.h5"), Path("model_log.txt")}; + Write("model_results.h5", "previous final"); + const auto status = fesa::FinalizePreparedAnalysisResult( + paths, prepared.Value().domain, outcome.Value(), + prepared.Value().diagnostics, linear_writer, nonlinear_writer, + eigen_writer); + EXPECT_EQ(status.Category(), fesa::FailureCategory::kOutput); + ASSERT_EQ(status.Diagnostics().size(), 1U); + EXPECT_EQ(status.Diagnostics()[0].code, "missing-thermal-writer"); + EXPECT_EQ(ReadBytes(paths.results_path), "previous final"); +} + +TEST_F(ThermalApplicationTest, + ExistingStructuralStaticNonlinearAndHhtRoutesRemainAvailable) { + auto base = Replace(Deck(), "type=DS4", "type=S4"); + base = Replace(base, "*Conductivity\n1\n*Specific Heat\n1", + "*Elastic\n1000,.25"); + base.erase(base.find("*Initial Conditions")); + base += "*Boundary\nAll,1,6\n"; + for (const auto& step : + {"*Step,name=Structural,nlgeom=NO\n*Static\n.1,.1,.01,.1\n*End Step\n", + "*Step,name=Structural,nlgeom=YES\n*Static\n.1,1.,.01,.1\n*End Step\n", + "*Step,name=Structural,nlgeom=YES,inc=10\n*Dynamic,direct\n.1,.2\n*End " + "Step\n"}) { + SCOPED_TRACE(step); + const bool dynamic = + std::string{step}.find("*Dynamic") != std::string::npos; + Write("model.inp", + (dynamic ? base : Replace(base, "*Density\n1\n", "")) + step); + const auto run = RunApplication(Path("model.inp")); + ASSERT_EQ(run.exit_code, 0) << run.error; + EXPECT_GT(H5Fis_hdf5(Path("model_results.h5").string().c_str()), 0); + } +} + +TEST_F(ThermalApplicationTest, + NativeExecutableAcceptsThermalInputAndRejectsInteriorBoundary) { + std::vector module(32768); + const DWORD length = GetModuleFileNameW(nullptr, module.data(), + static_cast(module.size())); + ASSERT_GT(length, 0U); + ASSERT_LT(length, module.size()); + const auto executable = + std::filesystem::path{std::wstring{module.data(), length}} + .parent_path() + .parent_path() / + "src/fesa/fesa.exe"; + ASSERT_TRUE(std::filesystem::exists(executable)); + std::string previous; + for (const bool valid : {true, false}) { + Write("native.inp", valid + ? Deck(".1,.1", "0") + : Replace(Deck(), "*Output,field", + "*Boundary\nAll,12,12,1\n*Output,field")); + std::wstring command = L"\"" + executable.wstring() + L"\" \"" + + Path("native.inp").wstring() + L"\""; + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + ASSERT_TRUE(CreateProcessW(executable.c_str(), command.data(), nullptr, + nullptr, FALSE, CREATE_NO_WINDOW, nullptr, + nullptr, &startup, &process)); + const DWORD wait = WaitForSingleObject(process.hProcess, 60000); + if (wait != WAIT_OBJECT_0) TerminateProcess(process.hProcess, 99); + DWORD exit = 99; + EXPECT_TRUE(GetExitCodeProcess(process.hProcess, &exit)); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + ASSERT_EQ(wait, WAIT_OBJECT_0); + EXPECT_EQ(exit, valid ? 0U : 3U); + EXPECT_GT(H5Fis_hdf5(Path("native_results.h5").string().c_str()), 0); + if (valid) { + previous = ReadBytes(Path("native_results.h5")); + } else { + EXPECT_EQ(ReadBytes(Path("native_results.h5")), previous); + } + } +} + +} // namespace