feat(result-contract-completion): step 2 — self-contained-hdf5

This commit is contained in:
KOKO\Mimi
2026-08-02 01:51:24 +09:00
parent 9d37465d92
commit 4d04f3dbbe
9 changed files with 1942 additions and 242 deletions
+7 -1
View File
@@ -641,8 +641,14 @@ add_test(
--gtest_filter=ResultRoundTrip.*
)
add_test(
NAME SelfContainedHdf5
COMMAND "$<TARGET_FILE:fesa_hdf5_results_tests>"
--gtest_filter=SelfContainedHdf5.*
)
set_property(
TEST Hdf5 ResultRoundTrip
TEST Hdf5 ResultRoundTrip SelfContainedHdf5
PROPERTY ENVIRONMENT_MODIFICATION
${FESA_DEPENDENCY_RUNTIME_MODIFICATIONS}
)
+263 -8
View File
@@ -146,11 +146,36 @@ void write_double_dataset(
values.data()));
}
void write_root_string_attribute(
const std::filesystem::path& path,
const std::string_view name,
const std::string_view value) {
const std::string encoded_path = hdf5_path(path);
const std::string owned_name{name};
const std::string owned_value{value};
TestHdf5Handle file{
H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
&H5Fclose,
};
TestHdf5Handle attribute{
H5Aopen(file.get(), owned_name.c_str(), H5P_DEFAULT),
&H5Aclose,
};
TestHdf5Handle type{H5Aget_type(attribute.get()), &H5Tclose};
const char* pointer = owned_value.c_str();
require_hdf5_status(H5Awrite(attribute.get(), type.get(), &pointer));
}
std::filesystem::path round_trip_path() {
return std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" /
"Temporary" / "fesa-round-trip.h5";
}
std::filesystem::path self_contained_path() {
return std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" /
"Temporary" / "fesa-self-contained.h5";
}
fesa::Domain make_domain() {
fesa::DomainBuilder builder;
builder.add_node({
@@ -180,7 +205,7 @@ fesa::Domain make_domain() {
0.032,
fesa::ShearPropertySource::input,
fesa::Vec3{0.0, 1.0, 0.0},
{},
{{0.25, -0.5}, {-0.75, 0.125}},
});
builder.add_section({
fesa::SectionId{12},
@@ -209,7 +234,22 @@ fesa::Domain make_domain() {
fesa::MaterialId{6},
fesa::SectionId{4},
});
builder.set_step({"Load/Case", {}, {}});
builder.add_node_set({"Fixed", {fesa::NodeId{42}}});
builder.add_node_set(
{"Loaded", {fesa::NodeId{7}, fesa::NodeId{42}}});
builder.add_element_set(
{"AllBeams", {fesa::ElementId{9}, fesa::ElementId{17}}});
builder.set_step({
"Load/Case",
{
{fesa::NodeId{42}, 1, 0.0},
{fesa::NodeId{7}, 6, 0.125},
},
{{
fesa::NodeId{7},
{100.0, -200.0, 300.0, -400.0, 500.0, -600.0},
}},
});
auto built = std::move(builder).build();
EXPECT_TRUE(built.domain.has_value());
@@ -219,7 +259,7 @@ fesa::Domain make_domain() {
fesa::ResultDatabase make_database() {
return {
"1.0.0",
"2.0.0",
{{
"Load/Case",
{{
@@ -248,6 +288,84 @@ fesa::ResultDatabase make_database() {
};
}
fesa::BeamSectionResult make_end_result(
const double xi,
const fesa::NodeId node,
const double offset) {
return {
xi,
node,
{
offset + 1.0,
offset + 2.0,
offset + 3.0,
offset + 4.0,
offset + 5.0,
offset + 6.0,
},
{
offset + 10.0,
offset + 20.0,
offset + 30.0,
offset + 40.0,
offset + 50.0,
offset + 60.0,
},
offset + 70.0,
{offset + 80.0, offset + 90.0},
};
}
fesa::ResultDatabase make_complete_database() {
fesa::ResultDatabase database = make_database();
auto& frame = database.steps[0].frames[0];
frame.element.beams = {
{
fesa::ElementId{9},
{"BeamPart", "Beam-1", 2001},
{
{1.0, 0.0, 0.0},
{0.0, 1.0, 0.0},
{0.0, 0.0, 1.0},
},
{
make_end_result(-1.0, fesa::NodeId{7}, 0.0),
make_end_result(1.0, fesa::NodeId{42}, 100.0),
},
},
{
fesa::ElementId{17},
{"BeamPart", "Beam-1", 2002},
{
{-1.0, 0.0, 0.0},
{0.0, 1.0, 0.0},
{0.0, 0.0, -1.0},
},
{
make_end_result(-1.0, fesa::NodeId{42}, 200.0),
make_end_result(1.0, fesa::NodeId{7}, 300.0),
},
},
};
frame.diagnostics = {
{
fesa::DiagnosticStage::solver,
fesa::Severity::warning,
"solver.residual",
"Residual diagnostic",
fesa::SourceLocation{"beam model.inp", 41, 7},
},
{
fesa::DiagnosticStage::results,
fesa::Severity::error,
"results.equilibrium",
"Equilibrium diagnostic",
std::nullopt,
},
};
return database;
}
bool has_results_error(
const std::vector<fesa::Diagnostic>& diagnostics,
const std::string_view code) {
@@ -276,7 +394,7 @@ TEST(ResultRoundTrip, PreservesMinimalSchemaModelAndNodalResults) {
ASSERT_TRUE(read.database.has_value());
ASSERT_TRUE(read.model.has_value());
EXPECT_EQ(read.database->schema_version, "1.0.0");
EXPECT_EQ(read.database->schema_version, "2.0.0");
ASSERT_EQ(read.database->steps.size(), 1U);
const auto& step = read.database->steps[0];
EXPECT_EQ(step.name, "Load/Case");
@@ -354,6 +472,134 @@ TEST(ResultRoundTrip, PreservesMinimalSchemaModelAndNodalResults) {
fesa::ShearPropertySource::phase1_default);
}
TEST(SelfContainedHdf5, PublicReaderReconstructsCompletePhase1Contract) {
const auto path = self_contained_path();
std::filesystem::create_directories(path.parent_path());
std::filesystem::remove(path);
ASSERT_TRUE(
fesa::write_hdf5(path, make_domain(), make_complete_database())
.empty());
const fesa::Hdf5ReadResult read = fesa::read_hdf5_results(path);
ASSERT_TRUE(read.diagnostics.empty());
ASSERT_TRUE(read.metadata.has_value());
ASSERT_TRUE(read.model.has_value());
ASSERT_TRUE(read.analysis.has_value());
ASSERT_TRUE(read.database.has_value());
EXPECT_EQ(read.metadata->schema_version, "2.0.0");
EXPECT_EQ(read.metadata->fesa_version, "0.1.0");
EXPECT_EQ(
read.metadata->unit_policy,
"consistent_input_units_no_conversion");
ASSERT_EQ(read.model->nodes.size(), 2U);
ASSERT_EQ(read.model->elements.size(), 2U);
EXPECT_EQ(
read.model->elements[0].origin,
(fesa::EntityOrigin{"BeamPart", "Beam-1", 2001}));
EXPECT_EQ(read.model->elements[0].material, fesa::MaterialId{6});
ASSERT_EQ(read.model->materials.size(), 1U);
EXPECT_EQ(read.model->materials[0].name, "Steel");
EXPECT_DOUBLE_EQ(read.model->materials[0].young, 210.0e9);
EXPECT_DOUBLE_EQ(read.model->materials[0].poisson, 0.3);
ASSERT_EQ(read.model->sections.size(), 2U);
EXPECT_EQ(read.model->sections[0].name, "InputShear");
EXPECT_DOUBLE_EQ(read.model->sections[0].area, 0.04);
EXPECT_DOUBLE_EQ(read.model->sections[0].iy, 1.2e-4);
EXPECT_DOUBLE_EQ(read.model->sections[0].iz, 1.4e-4);
EXPECT_DOUBLE_EQ(read.model->sections[0].torsion_j, 2.0e-4);
EXPECT_DOUBLE_EQ(read.model->sections[0].orientation.y, 1.0);
EXPECT_EQ(
read.model->sections[0].recovery_points,
(std::vector<std::array<double, 2>>{
{0.25, -0.5}, {-0.75, 0.125}}));
ASSERT_EQ(read.model->node_sets.size(), 2U);
EXPECT_EQ(read.model->node_sets[0].name, "Fixed");
EXPECT_EQ(
read.model->node_sets[1].members,
(std::vector<fesa::NodeId>{
fesa::NodeId{7}, fesa::NodeId{42}}));
ASSERT_EQ(read.model->element_sets.size(), 1U);
EXPECT_EQ(
read.model->element_sets[0].members,
(std::vector<fesa::ElementId>{
fesa::ElementId{9}, fesa::ElementId{17}}));
EXPECT_EQ(read.analysis->step.name, "Load/Case");
ASSERT_EQ(read.analysis->step.prescribed_dofs.size(), 2U);
EXPECT_EQ(
read.analysis->step.prescribed_dofs[1].node,
fesa::NodeId{7});
EXPECT_EQ(read.analysis->step.prescribed_dofs[1].dof, 6U);
EXPECT_DOUBLE_EQ(
read.analysis->step.prescribed_dofs[1].value, 0.125);
ASSERT_EQ(read.analysis->step.nodal_loads.size(), 1U);
EXPECT_DOUBLE_EQ(
read.analysis->step.nodal_loads[0].values[5], -600.0);
EXPECT_EQ(read.analysis->solver.backend, "mkl_pardiso");
EXPECT_EQ(
read.analysis->solver.constraint_method,
"essential_dof_elimination");
EXPECT_EQ(read.analysis->solver.assembly, "deterministic_serial");
const auto& frame = read.database->steps[0].frames[0];
ASSERT_EQ(frame.element.beams.size(), 2U);
EXPECT_EQ(frame.element.beams[0].element, fesa::ElementId{9});
EXPECT_DOUBLE_EQ(frame.element.beams[0].local_frame.ex.x, 1.0);
EXPECT_EQ(
frame.element.beams[0].end_results[0].end_node,
fesa::NodeId{7});
EXPECT_EQ(
frame.element.beams[0].end_results[1].section_force,
(std::array<double, 6>{
110.0, 120.0, 130.0, 140.0, 150.0, 160.0}));
EXPECT_EQ(
frame.element.beams[1].end_results[0].sigma_xx,
(std::vector<double>{280.0, 290.0}));
ASSERT_EQ(frame.diagnostics.size(), 2U);
EXPECT_EQ(frame.diagnostics[0].stage, fesa::DiagnosticStage::solver);
EXPECT_EQ(frame.diagnostics[0].severity, fesa::Severity::warning);
ASSERT_TRUE(frame.diagnostics[0].source.has_value());
EXPECT_EQ(frame.diagnostics[0].source->file, "beam model.inp");
EXPECT_EQ(frame.diagnostics[0].source->line, 41U);
EXPECT_EQ(frame.diagnostics[0].source->column, 7U);
EXPECT_FALSE(frame.diagnostics[1].source.has_value());
}
TEST(Hdf5, RejectsVersion1AfterMajorSchemaChange) {
const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
"Testing" / "Temporary" / "fesa-schema-1.h5";
std::filesystem::remove(path);
auto database = make_database();
database.schema_version = "1.0.0";
const auto diagnostics =
fesa::write_hdf5(path, make_domain(), database);
EXPECT_TRUE(has_results_error(diagnostics, "hdf5.unsupported_schema"));
EXPECT_FALSE(std::filesystem::exists(path));
}
TEST(Hdf5, RejectsUnlistedMinorSchemaVersion) {
const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
"Testing" / "Temporary" / "fesa-schema-2-1.h5";
std::filesystem::remove(path);
ASSERT_TRUE(
fesa::write_hdf5(path, make_domain(), make_database()).empty());
write_root_string_attribute(path, "schema_version", "2.1.0");
const auto read = fesa::read_hdf5_results(path);
EXPECT_FALSE(read.database.has_value());
EXPECT_FALSE(read.model.has_value());
EXPECT_FALSE(read.metadata.has_value());
EXPECT_FALSE(read.analysis.has_value());
EXPECT_TRUE(has_results_error(
read.diagnostics, "hdf5.unsupported_schema"));
}
TEST(Hdf5, RejectsInvalidResultDatabaseBeforeWriting) {
const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
"Testing" / "Temporary" / "fesa-invalid.h5";
@@ -369,7 +615,7 @@ TEST(Hdf5, RejectsInvalidResultDatabaseBeforeWriting) {
EXPECT_FALSE(std::filesystem::exists(path));
}
TEST(Hdf5, RejectsFrameDiagnosticsThatSchemaCannotRepresent) {
TEST(Hdf5, PreservesFrameDiagnosticsRepresentedBySchema) {
const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} /
"Testing" / "Temporary" /
"fesa-unsupported-diagnostics.h5";
@@ -386,9 +632,18 @@ TEST(Hdf5, RejectsFrameDiagnosticsThatSchemaCannotRepresent) {
const auto diagnostics = fesa::write_hdf5(path, domain, database);
EXPECT_TRUE(has_results_error(
diagnostics, "hdf5.unsupported_result_diagnostics"));
EXPECT_FALSE(std::filesystem::exists(path));
ASSERT_TRUE(diagnostics.empty());
const auto read = fesa::read_hdf5_results(path);
ASSERT_TRUE(read.diagnostics.empty());
ASSERT_TRUE(read.database.has_value());
const auto& stored = read.database->steps[0].frames[0].diagnostics;
ASSERT_EQ(stored.size(), 1U);
EXPECT_EQ(stored[0].stage, fesa::DiagnosticStage::solver);
EXPECT_EQ(stored[0].severity, fesa::Severity::warning);
EXPECT_EQ(stored[0].code, "solver.residual");
EXPECT_EQ(stored[0].message, "Residual diagnostic");
EXPECT_FALSE(stored[0].source.has_value());
}
TEST(Hdf5, RejectsResultNodeMissingFromDomainBeforeWriting) {
@@ -94,7 +94,7 @@ TEST(MinimalCantileverPipeline, WritesReadableFiniteEquilibratedResults) {
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");
EXPECT_EQ(read.database->schema_version, "2.0.0");
ASSERT_EQ(read.model->nodes.size(), 2U);
EXPECT_EQ(read.model->nodes[0].id, fesa::NodeId{0});
@@ -149,7 +149,7 @@ TEST(LinearStaticAnalysis, SolvesHandCalculatedAxialBeamInNodeIdOrder) {
ASSERT_TRUE(run.results.has_value());
EXPECT_TRUE(run.diagnostics.empty());
EXPECT_TRUE(fesa::validate_result_database(*run.results).succeeded);
EXPECT_EQ(run.results->schema_version, "1.0.0");
EXPECT_EQ(run.results->schema_version, "2.0.0");
ASSERT_EQ(run.results->steps.size(), 1);
EXPECT_EQ(run.results->steps[0].name, "Load");
ASSERT_EQ(run.results->steps[0].frames.size(), 1);
+1 -1
View File
@@ -64,7 +64,7 @@ fesa::ResultDatabase valid_database() {
{},
};
fesa::ResultStep step{"Load", {std::move(frame)}};
return {"1.0.0", {std::move(step)}};
return {"2.0.0", {std::move(step)}};
}
bool has_diagnostic(