feat(linear-static-3d-euler-beam): step 24 - linear-static-cli

This commit is contained in:
KOKO\Mimi
2026-08-09 22:54:59 +09:00
parent 286424bfde
commit a9d93bb206
14 changed files with 3919 additions and 31 deletions
@@ -0,0 +1,300 @@
#include "fesa/analysis/linear_static_analysis.hpp"
#include "fesa/assembly/parallel_for.hpp"
#include "fesa/results/results_writer.hpp"
#include "fesa/solvers/linear/mkl_pardiso_solver.hpp"
#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace {
class TempDirectory {
public:
explicit TempDirectory(const std::string& label) {
static std::atomic<unsigned long long> sequence{0U};
const auto tick = std::chrono::steady_clock::now()
.time_since_epoch()
.count();
path_ = std::filesystem::temp_directory_path() /
("fesa-step24-analysis-" + label + "-" +
std::to_string(tick) + "-" +
std::to_string(sequence.fetch_add(1U)));
std::error_code error;
if (!std::filesystem::create_directory(path_, error) || error) {
throw std::runtime_error{"Unable to create the Step 24 analysis fixture."};
}
}
TempDirectory(const TempDirectory&) = delete;
TempDirectory& operator=(const TempDirectory&) = delete;
~TempDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
const std::filesystem::path& path() const noexcept { return path_; }
private:
std::filesystem::path path_;
};
void writeText(const std::filesystem::path& path, const std::string& text) {
std::ofstream stream{path, std::ios::binary | std::ios::trunc};
stream.write(text.data(), static_cast<std::streamsize>(text.size()));
if (!stream) {
throw std::runtime_error{"Unable to write the Step 24 analysis input."};
}
}
std::string axialDeck(const double rootUx, const double tipForce) {
return R"inp(*Part, name=BeamPart
*Node
1, 0., 0., 0.
2, 2., 0., 0.
*Element, type=B33
1, 1, 2
*Elset, elset=BeamSet
1
*Beam General Section, elset=BeamSet, material=Steel, section=GENERAL
2., 0.5, 0., 0.75, 0.25
0., 1., 0.
*End Part
*Assembly, name=Assembly
*Instance, name=Beam-1, part=BeamPart
*End Instance
*Nset, nset=Root, instance=Beam-1
1
*Nset, nset=Tip, instance=Beam-1
2
*End Assembly
*Material, name=Steel
*Elastic
100., 0.25
*Boundary
Root, 1, 1, )inp" + std::to_string(rootUx) + R"inp(
Root, 2, 6
Tip, 2, 6
*Step, name=Load, nlgeom=NO
*Static
0.1, 1., 0.01, 1.
*Cload
Tip, 1, )inp" + std::to_string(tipForce) + R"inp(
*End Step
)inp";
}
// The pure Template Method spy makes the eight public lifecycle hooks observable
// without coupling the ordering assertion to any solver backend.
class SpyAnalysis final : public fesa::Analysis {
public:
const std::vector<std::string>& events() const noexcept { return events_; }
protected:
fesa::Status initialize(const fesa::AnalysisRequest&) override {
return record("initialize");
}
fesa::Status buildAnalysisModel() override {
return record("build-analysis-model");
}
fesa::Status buildDofMapAndSparsePattern() override {
return record("build-dof-map-and-sparse-pattern");
}
fesa::Status assembleAndPartitionStiffness() override {
return record("assemble-and-partition-stiffness");
}
fesa::Status factorize() override { return record("factorize"); }
fesa::Status assembleLoadsAndEffectiveRhs() override {
return record("assemble-loads-and-effective-rhs");
}
fesa::Status substituteAndReconstruct() override {
return record("substitute-and-reconstruct");
}
fesa::Status recoverAndWriteResults() override {
return record("recover-and-write-results");
}
private:
fesa::Status record(const char* event) {
events_.emplace_back(event);
return fesa::Status::ok();
}
std::vector<std::string> events_;
};
// The solver spy records only the adapter-boundary operations. In particular,
// solve() cannot conceal a second factorization call.
class SpyLinearSolver final : public fesa::LinearSolver {
public:
explicit SpyLinearSolver(std::vector<std::string>& events)
: events_{events} {}
fesa::Status factorize(const fesa::SparseMatrix&) override {
++factorizeCalls_;
events_.emplace_back("solver-factorize");
return fesa::Status::ok();
}
fesa::Status solve(
const fesa::Vector& rhs, fesa::Vector& solution) const override {
++solveCalls_;
events_.emplace_back("solver-solve");
for (std::size_t index = 0U;
index < rhs.size() && index < solution.size(); ++index) {
solution[index] = 0.0;
}
return fesa::Status::ok();
}
int factorizeCalls() const noexcept { return factorizeCalls_; }
int solveCalls() const noexcept { return solveCalls_; }
private:
std::vector<std::string>& events_;
int factorizeCalls_{0};
mutable int solveCalls_{0};
};
class SpyResultsWriter final : public fesa::ResultsWriter {
public:
explicit SpyResultsWriter(std::vector<std::string>& events)
: events_{events} {}
fesa::Status write(
const std::filesystem::path&,
const fesa::Domain&,
const fesa::AnalysisState&,
const std::vector<fesa::Diagnostic>&) override {
++writeCalls_;
events_.emplace_back("writer-write");
return fesa::Status::ok();
}
int writeCalls() const noexcept { return writeCalls_; }
private:
std::vector<std::string>& events_;
int writeCalls_{0};
};
class CapturingResultsWriter final : public fesa::ResultsWriter {
public:
fesa::Status write(
const std::filesystem::path& outputPath,
const fesa::Domain& domain,
const fesa::AnalysisState& state,
const std::vector<fesa::Diagnostic>& diagnostics) override {
outputPath_ = outputPath;
nodeCount_ = domain.nodes().size();
state_ = std::make_unique<fesa::AnalysisState>(state);
diagnostics_ = diagnostics;
return fesa::Status::ok();
}
const fesa::AnalysisState& state() const {
if (!state_) {
throw std::logic_error{"No AnalysisState was captured."};
}
return *state_;
}
const std::filesystem::path& outputPath() const noexcept {
return outputPath_;
}
std::size_t nodeCount() const noexcept { return nodeCount_; }
const std::vector<fesa::Diagnostic>& diagnostics() const noexcept {
return diagnostics_;
}
private:
std::filesystem::path outputPath_;
std::size_t nodeCount_{0U};
std::unique_ptr<fesa::AnalysisState> state_;
std::vector<fesa::Diagnostic> diagnostics_;
};
} // namespace
TEST(LinearStaticCli, FactorizesBeforeLoadAndSolvesWithoutRefactorization) {
SpyAnalysis lifecycle;
const fesa::AnalysisRequest emptyRequest{};
ASSERT_TRUE(lifecycle.run(emptyRequest).isOk());
EXPECT_EQ(
lifecycle.events(),
(std::vector<std::string>{
"initialize",
"build-analysis-model",
"build-dof-map-and-sparse-pattern",
"assemble-and-partition-stiffness",
"factorize",
"assemble-loads-and-effective-rhs",
"substitute-and-reconstruct",
"recover-and-write-results"}));
TempDirectory directory{"order"};
const auto input = directory.path() / "order.inp";
const auto output = directory.path() / "results.h5";
writeText(input, axialDeck(0.0, 0.0));
std::vector<std::string> adapterEvents;
fesa::SerialParallelFor serial;
SpyLinearSolver solver{adapterEvents};
SpyResultsWriter writer{adapterEvents};
fesa::LinearStaticAnalysis analysis{serial, solver, writer};
ASSERT_TRUE(analysis.run({input, output}).isOk());
EXPECT_EQ(solver.factorizeCalls(), 1);
EXPECT_EQ(solver.solveCalls(), 1);
EXPECT_EQ(writer.writeCalls(), 1);
EXPECT_EQ(
adapterEvents,
(std::vector<std::string>{
"solver-factorize", "solver-solve", "writer-write"}));
}
TEST(LinearStaticCli, RealPipelineHandlesAnalyticalAndNonzeroPrescription) {
TempDirectory directory{"analytical"};
const auto input = directory.path() / "prescribed-axial.inp";
const auto output = directory.path() / "captured-results.h5";
writeText(input, axialDeck(0.1, 10.0));
fesa::SerialParallelFor serial;
fesa::MklPardisoSolver solver;
CapturingResultsWriter writer;
fesa::LinearStaticAnalysis analysis{serial, solver, writer};
const auto status = analysis.run({input, output});
ASSERT_TRUE(status.isOk());
EXPECT_EQ(writer.outputPath(), output);
EXPECT_EQ(writer.nodeCount(), 2U);
EXPECT_TRUE(writer.diagnostics().empty());
const auto& state = writer.state();
ASSERT_EQ(state.displacement().size(), 12U);
EXPECT_EQ(state.identity().stepName, "Step-1");
EXPECT_EQ(state.identity().frameIndex, 0U);
// EA/L = 100 for this fixture, so u_tip = 0.1 + 10/100 = 0.2.
EXPECT_NEAR(state.displacement()[0U], 0.1, 1.0e-12);
EXPECT_NEAR(state.displacement()[6U], 0.2, 2.0e-10);
EXPECT_NEAR(state.externalForce()[6U], 10.0, 1.0e-12);
EXPECT_NEAR(state.internalForce()[0U], -10.0, 1.0e-9);
EXPECT_NEAR(state.internalForce()[6U], 10.0, 1.0e-9);
EXPECT_NEAR(state.reaction()[0U], -10.0, 1.0e-9);
EXPECT_NEAR(state.residual()[6U], 0.0, 1.0e-9);
EXPECT_EQ(state.endpointResults().size(), 2U);
EXPECT_EQ(state.gaussResults().size(), 2U);
EXPECT_EQ(state.stressResults().size(), 2U);
}
@@ -0,0 +1,400 @@
#include "fesa/app/fesa_application.hpp"
#include <gtest/gtest.h>
#include <hdf5.h>
#include <array>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace {
constexpr const char* kStepRoot = "/steps/Step-1/frames/0";
class TempDirectory {
public:
explicit TempDirectory(const std::string& label) {
static std::atomic<unsigned long long> sequence{0U};
const auto tick = std::chrono::steady_clock::now()
.time_since_epoch()
.count();
path_ = std::filesystem::temp_directory_path() /
("fesa-step24-app-" + label + "-" +
std::to_string(tick) + "-" +
std::to_string(sequence.fetch_add(1U)));
std::error_code error;
if (!std::filesystem::create_directory(path_, error) || error) {
throw std::runtime_error{"Unable to create the Step 24 app fixture."};
}
}
TempDirectory(const TempDirectory&) = delete;
TempDirectory& operator=(const TempDirectory&) = delete;
~TempDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
const std::filesystem::path& path() const noexcept { return path_; }
private:
std::filesystem::path path_;
};
class CurrentDirectoryGuard {
public:
explicit CurrentDirectoryGuard(const std::filesystem::path& replacement)
: original_{std::filesystem::current_path()} {
std::filesystem::current_path(replacement);
}
CurrentDirectoryGuard(const CurrentDirectoryGuard&) = delete;
CurrentDirectoryGuard& operator=(const CurrentDirectoryGuard&) = delete;
~CurrentDirectoryGuard() {
std::error_code ignored;
std::filesystem::current_path(original_, ignored);
}
private:
std::filesystem::path original_;
};
class Hdf5Handle {
public:
using Closer = herr_t (*)(hid_t);
Hdf5Handle(const hid_t value, Closer closer)
: value_{value}, closer_{closer} {}
Hdf5Handle(const Hdf5Handle&) = delete;
Hdf5Handle& operator=(const Hdf5Handle&) = delete;
Hdf5Handle(Hdf5Handle&& other) noexcept
: value_{other.value_}, closer_{other.closer_} {
other.value_ = -1;
other.closer_ = nullptr;
}
~Hdf5Handle() {
if (value_ >= 0 && closer_ != nullptr) {
(void)closer_(value_);
}
}
hid_t get() const noexcept { return value_; }
private:
hid_t value_{-1};
Closer closer_{nullptr};
};
void writeText(const std::filesystem::path& path, const std::string& text) {
std::ofstream stream{path, std::ios::binary | std::ios::trunc};
stream.write(text.data(), static_cast<std::streamsize>(text.size()));
if (!stream) {
throw std::runtime_error{"Unable to write the Step 24 app input."};
}
}
std::string axialDeck(
const bool constrained,
const bool zeroLength,
const bool outputRequests) {
const std::string secondNode = zeroLength
? "2, 0., 0., 0.\n"
: "2, 2., 0., 0.\n";
const std::string boundaries = constrained
? "*Boundary\nRoot, 1, 6\nTip, 2, 6\n"
: "";
const std::string outputs = outputRequests
? R"inp(*Output, field
*Node Output
U, RF
*Element Output, directions=YES
S, SF
*Output, history
*Contact Output
)inp"
: "";
return std::string{R"inp(*Part, name=BeamPart
*Node
1, 0., 0., 0.
)inp"} + secondNode + R"inp(*Element, type=B33
1, 1, 2
*Elset, elset=BeamSet
1
*Beam General Section, elset=BeamSet, material=Steel, section=GENERAL
2., 0.5, 0., 0.75, 0.25
0., 1., 0.
*End Part
*Assembly, name=Assembly
*Instance, name=Beam-1, part=BeamPart
*End Instance
*Nset, nset=Root, instance=Beam-1
1
*Nset, nset=Tip, instance=Beam-1
2
*End Assembly
*Material, name=Steel
*Elastic
100., 0.25
)inp" + boundaries + R"inp(*Step, name=Load, nlgeom=NO
*Static
0.1, 1., 0.01, 1.
*Cload
Tip, 1, 10.
)inp" + outputs + R"inp(*End Step
)inp";
}
Hdf5Handle openFile(const std::filesystem::path& path) {
const hid_t file = H5Fopen(
path.string().c_str(), H5F_ACC_RDONLY, H5P_DEFAULT);
if (file < 0) {
throw std::runtime_error{"Unable to open the CLI HDF5 artifact."};
}
return Hdf5Handle{file, H5Fclose};
}
Hdf5Handle openDataset(const hid_t file, const std::string& path) {
const hid_t dataset = H5Dopen2(file, path.c_str(), H5P_DEFAULT);
if (dataset < 0) {
throw std::runtime_error{"Unable to open mandatory dataset: " + path};
}
return Hdf5Handle{dataset, H5Dclose};
}
std::vector<hsize_t> datasetDimensions(
const hid_t file, const std::string& path) {
const auto dataset = openDataset(file, path);
Hdf5Handle space{H5Dget_space(dataset.get()), H5Sclose};
const int rank = H5Sget_simple_extent_ndims(space.get());
if (space.get() < 0 || rank < 0) {
throw std::runtime_error{"Unable to inspect mandatory dataset dimensions."};
}
std::vector<hsize_t> dimensions(static_cast<std::size_t>(rank));
if (rank > 0 &&
H5Sget_simple_extent_dims(space.get(), dimensions.data(), nullptr) < 0) {
throw std::runtime_error{"Unable to read mandatory dataset dimensions."};
}
return dimensions;
}
std::vector<double> readDoubleDataset(
const hid_t file, const std::string& path) {
const auto dimensions = datasetDimensions(file, path);
std::size_t count = 1U;
for (const hsize_t dimension : dimensions) {
count *= static_cast<std::size_t>(dimension);
}
const auto dataset = openDataset(file, path);
std::vector<double> values(count);
if (!values.empty() &&
H5Dread(dataset.get(), H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL,
H5P_DEFAULT, values.data()) < 0) {
throw std::runtime_error{"Unable to read mandatory numeric results."};
}
return values;
}
std::string readStringAttribute(const hid_t object, const char* name) {
Hdf5Handle attribute{H5Aopen(object, name, H5P_DEFAULT), H5Aclose};
Hdf5Handle type{H5Aget_type(attribute.get()), H5Tclose};
if (attribute.get() < 0 || type.get() < 0 ||
H5Tget_class(type.get()) != H5T_STRING ||
H5Tis_variable_str(type.get()) <= 0) {
throw std::runtime_error{"Expected a variable-length string attribute."};
}
char* raw = nullptr;
if (H5Aread(attribute.get(), type.get(), &raw) < 0 || raw == nullptr) {
throw std::runtime_error{"Unable to read the HDF5 identity attribute."};
}
const std::string value{raw};
(void)H5free_memory(raw);
return value;
}
void expectMandatoryInventory(const hid_t file) {
for (const char* path : {
"/metadata",
"/model/nodes",
"/model/elements",
"/steps/Step-1/frames/0/nodal/displacement",
"/steps/Step-1/frames/0/nodal/reaction",
"/steps/Step-1/frames/0/element/end_force_local",
"/steps/Step-1/frames/0/element/section_resultant",
"/steps/Step-1/frames/0/element/generalized_strain",
"/steps/Step-1/frames/0/element/generalized_resultant",
"/steps/Step-1/frames/0/element/stress_s11",
"/diagnostics"}) {
EXPECT_GT(H5Lexists(file, path, H5P_DEFAULT), 0) << path;
}
}
void expectFesaHdf5Identity(
const std::filesystem::path& output,
const std::filesystem::path& input) {
ASSERT_TRUE(std::filesystem::exists(output));
ASSERT_GT(H5Fis_hdf5(output.string().c_str()), 0);
const auto file = openFile(output);
expectMandatoryInventory(file.get());
Hdf5Handle metadata{
H5Gopen2(file.get(), "/metadata", H5P_DEFAULT), H5Gclose};
ASSERT_GE(metadata.get(), 0);
EXPECT_EQ(
readStringAttribute(metadata.get(), "feature_id"),
"linear-static-3d-euler-beam");
const std::string normalizedInput =
std::filesystem::absolute(input).lexically_normal().generic_u8string();
EXPECT_EQ(
readStringAttribute(metadata.get(), "source_input_identity").find(
"path=" + normalizedInput + ";content_identity="),
0U);
}
struct AppRun {
int exitCode;
std::string standardError;
};
AppRun runApplication(const std::vector<std::string>& arguments) {
testing::internal::CaptureStderr();
try {
const int exitCode = fesa::FesaApplication{}.run(arguments);
return {exitCode, testing::internal::GetCapturedStderr()};
} catch (...) {
(void)testing::internal::GetCapturedStderr();
throw;
}
}
void expectDiagnosticFieldOrder(const std::string& text) {
ASSERT_FALSE(text.empty());
std::size_t cursor = 0U;
for (const char* field : {
"severity", "code", "file", "line", "keyword",
"entity_identity", "message"}) {
const auto position = text.find(field, cursor);
ASSERT_NE(position, std::string::npos)
<< "Missing or out-of-order diagnostic field: " << field
<< "\nstderr:\n" << text;
cursor = position + std::string{field}.size();
}
}
std::vector<std::string> explicitOutputArguments(
const std::filesystem::path& input,
const std::filesystem::path& output) {
// FesaApplication receives argv[0]-excluded operands and options.
return {input.string(), "--output", output.string()};
}
} // namespace
TEST(LinearStaticCli, DefaultAndExplicitOutputProduceFesaHdf5) {
TempDirectory directory{"paths"};
const auto input = directory.path() / "model.inp";
writeText(input, axialDeck(true, false, false));
const auto defaultOutput = directory.path() / "results.h5";
{
CurrentDirectoryGuard currentDirectory{directory.path()};
const auto result = runApplication({input.string()});
ASSERT_EQ(result.exitCode, 0) << result.standardError;
}
expectFesaHdf5Identity(defaultOutput, input);
const auto explicitOutput = directory.path() / "named-output.h5";
const auto result = runApplication(
explicitOutputArguments(input, explicitOutput));
ASSERT_EQ(result.exitCode, 0) << result.standardError;
expectFesaHdf5Identity(explicitOutput, input);
}
TEST(LinearStaticCli, ReturnsEveryExactExitCodeAndOrderedDiagnostic) {
TempDirectory directory{"exit-codes"};
const auto validInput = directory.path() / "valid.inp";
const auto modelInput = directory.path() / "invalid-model.inp";
const auto solverInput = directory.path() / "singular.inp";
writeText(validInput, axialDeck(true, false, false));
writeText(modelInput, axialDeck(true, true, false));
writeText(solverInput, axialDeck(false, false, false));
const auto success = runApplication(explicitOutputArguments(
validInput, directory.path() / "success.h5"));
const auto usage = runApplication({});
const auto missingInput = directory.path() / "missing.inp";
const auto input = runApplication({missingInput.string()});
const auto repeatedInput = runApplication({missingInput.string()});
const auto model = runApplication(explicitOutputArguments(
modelInput, directory.path() / "model-failure.h5"));
const auto solver = runApplication(explicitOutputArguments(
solverInput, directory.path() / "solver-failure.h5"));
const auto output = runApplication(explicitOutputArguments(
validInput,
directory.path() / "nonexistent-parent" / "results.h5"));
EXPECT_EQ(success.exitCode, 0) << success.standardError;
EXPECT_EQ(usage.exitCode, 2);
EXPECT_EQ(input.exitCode, 3);
EXPECT_EQ(model.exitCode, 4);
EXPECT_EQ(solver.exitCode, 5);
EXPECT_EQ(output.exitCode, 6);
expectDiagnosticFieldOrder(usage.standardError);
expectDiagnosticFieldOrder(input.standardError);
expectDiagnosticFieldOrder(model.standardError);
expectDiagnosticFieldOrder(solver.standardError);
expectDiagnosticFieldOrder(output.standardError);
EXPECT_EQ(repeatedInput.exitCode, 3);
EXPECT_EQ(repeatedInput.standardError, input.standardError);
}
TEST(LinearStaticCli, OutputRequestsDoNotFilterMandatoryResults) {
TempDirectory directory{"output-requests"};
const auto plainInput = directory.path() / "plain.inp";
const auto requestedInput = directory.path() / "requested.inp";
const auto plainOutput = directory.path() / "plain.h5";
const auto requestedOutput = directory.path() / "requested.h5";
writeText(plainInput, axialDeck(true, false, false));
writeText(requestedInput, axialDeck(true, false, true));
const auto plain = runApplication(
explicitOutputArguments(plainInput, plainOutput));
const auto requested = runApplication(
explicitOutputArguments(requestedInput, requestedOutput));
ASSERT_EQ(plain.exitCode, 0) << plain.standardError;
ASSERT_EQ(requested.exitCode, 0) << requested.standardError;
const auto plainFile = openFile(plainOutput);
const auto requestedFile = openFile(requestedOutput);
expectMandatoryInventory(plainFile.get());
expectMandatoryInventory(requestedFile.get());
for (const char* suffix : {
"/nodal/displacement",
"/nodal/reaction",
"/element/end_force_local",
"/element/section_resultant",
"/element/generalized_strain",
"/element/generalized_resultant"}) {
const std::string path = std::string{kStepRoot} + suffix;
EXPECT_EQ(
readDoubleDataset(requestedFile.get(), path),
readDoubleDataset(plainFile.get(), path))
<< path;
}
EXPECT_EQ(datasetDimensions(plainFile.get(), "/diagnostics"),
std::vector<hsize_t>({0U}));
const auto requestedDiagnostics =
datasetDimensions(requestedFile.get(), "/diagnostics");
ASSERT_EQ(requestedDiagnostics.size(), 1U);
EXPECT_GT(requestedDiagnostics[0U], 0U);
}