feat: add eigen2 frequency pipeline and fail-closed validation

Implement MITC4 mass, constrained whitening, MKL FEAST, modal HDF5, and deterministic Value comparison. MSVC Debug builds; 394 of 395 tests pass. Original-model all-c residual and the documented S3 static contract conflict remain unresolved; this is an implementation checkpoint, not feature acceptance.
This commit is contained in:
KOKO\Mimi
2026-09-09 03:14:30 +09:00
parent e74ac247db
commit 22c9d4435c
39 changed files with 7240 additions and 541 deletions
@@ -157,6 +157,9 @@ TEST(DomainMappingComponents,
EXPECT_EQ(domain.Steps()[0].Loads().size(), 1U);
ASSERT_EQ(domain.Warnings().size(), 3U);
EXPECT_EQ(domain.Warnings()[0].keyword, "PREPRINT");
EXPECT_EQ(
domain.Warnings()[0].message,
"The allowlisted Abaqus keyword is ignored without semantic effect.");
EXPECT_EQ(domain.Warnings()[1].keyword, "OUTPUT");
EXPECT_EQ(domain.Warnings()[2].keyword, "NODE OUTPUT");
}
+207
View File
@@ -0,0 +1,207 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include "fesa/io/abaqus/domain_mapper.h"
#include "fesa/io/abaqus/input_reader.h"
namespace {
class TemporaryInputFile {
public:
TemporaryInputFile(const std::string& stem, const std::string& content)
: path_{std::filesystem::temp_directory_path() /
("fesa-eigen-domain-" + stem + ".inp")} {
std::ofstream stream{path_, std::ios::binary | std::ios::trunc};
stream.write(content.data(), static_cast<std::streamsize>(content.size()));
if (!stream) {
throw std::runtime_error{"Unable to create eigen domain fixture."};
}
}
~TemporaryInputFile() {
std::error_code error;
std::filesystem::remove(path_, error);
}
const std::filesystem::path& Path() const noexcept { return path_; }
private:
std::filesystem::path path_;
};
fesa::Result<fesa::Domain> MapText(const std::string& stem,
const std::string& content) {
const TemporaryInputFile input{stem, content};
auto parsed = fesa::AbaqusInputReader{}.Read(input.Path());
if (!parsed.HasValue()) {
return fesa::Result<fesa::Domain>::Failure(parsed.GetStatus());
}
return fesa::AbaqusDomainMapper{}.Map(parsed.Value());
}
std::string EigenDeck(const std::string& density = "2700.,",
const std::string& frequency = "10, , , , ,") {
return R"inp(*Part, name=ShellPart
*Node
1, 0., 0., 0.
2, 1., 0., 0.
3, 1., 1., 0.
4, 0., 1., 0.
*Element, type=S4R
1, 1, 2, 3, 4
*Elset, elset=ShellSet
1
*Shell Section, elset=ShellSet, material=Steel
0.5, 5
*End Part
*Assembly, name=Assembly
*Instance, name=Shell-1, part=ShellPart
*End Instance
*Nset, nset=Root, instance=Shell-1
1
*End Assembly
*Material, name=Steel
*Density
)inp" + density +
R"inp(
*Elastic
2.1e11, 0.3
*Boundary
Root, 1, 6
*Step, name=Step-2, nlgeom=NO, perturbation
*Frequency, eigensolver=Lanczos, sim, acoustic coupling=on, normalization=mass
)inp" + frequency +
R"inp(
*Restart, write, frequency=0
*Output, field
*Node Output, variable=PRESELECT
*Output, history
*Energy Output, variable=ALL
*End Step
)inp";
}
const fesa::Diagnostic* FindDiagnostic(const fesa::Status& status,
const std::string& code) {
for (const auto& diagnostic : status.Diagnostics()) {
if (diagnostic.code == code) {
return &diagnostic;
}
}
return nullptr;
}
TEST(EigenDomainMapping, PreservesFrequencyDensityAndSourceStepIdentity) {
auto result = MapText("accepted", EigenDeck());
ASSERT_TRUE(result.HasValue());
const fesa::Domain& domain = result.Value();
ASSERT_EQ(domain.Densities().size(), 1U);
EXPECT_EQ(domain.Densities()[0].material_index, fesa::EntityIndex{0U});
EXPECT_DOUBLE_EQ(domain.Densities()[0].rho, 2700.0);
ASSERT_NE(domain.Frequency(), nullptr);
EXPECT_EQ(domain.Frequency()->source_step_name_raw, "Step-2");
EXPECT_EQ(domain.Frequency()->source_step_name_canonical, "STEP-2");
EXPECT_EQ(domain.Frequency()->requested_mode_count, 10);
EXPECT_TRUE(domain.Frequency()->perturbation_explicit);
EXPECT_TRUE(domain.Frequency()->sim_explicit);
ASSERT_EQ(domain.Steps().Size(), 1U);
EXPECT_TRUE(domain.Steps()[0U].IsFrequency());
EXPECT_EQ(domain.Steps()[0U].Frequency(), domain.Frequency());
EXPECT_EQ(domain.Steps()[0U].Name(), "Step-2");
ASSERT_EQ(domain.Steps()[0U].BoundaryConditions().size(), 6U);
EXPECT_TRUE(domain.Steps()[0U].Loads().empty());
}
TEST(EigenDomainMapping, RejectsInvalidDensityAndFrequencySlots) {
auto density = MapText("bad-density", EigenDeck("2700., 1."));
ASSERT_FALSE(density.HasValue());
EXPECT_NE(FindDiagnostic(density.GetStatus(), "invalid-density-data"),
nullptr);
auto slots = MapText("bad-slots", EigenDeck("2700.,", "10, 0"));
ASSERT_FALSE(slots.HasValue());
EXPECT_NE(FindDiagnostic(slots.GetStatus(), "invalid-frequency-data"),
nullptr);
}
TEST(EigenDomainMapping, RejectsMissingActiveDensityAndNonzeroBoundary) {
std::string missing_deck = EigenDeck();
const std::string density_block = "*Density\n2700.,\n";
const std::size_t density_location = missing_deck.find(density_block);
ASSERT_NE(density_location, std::string::npos);
missing_deck.erase(density_location, density_block.size());
auto missing = MapText("missing-density", missing_deck);
ASSERT_FALSE(missing.HasValue());
EXPECT_NE(FindDiagnostic(missing.GetStatus(), "missing-material-density"),
nullptr);
std::string nonzero = EigenDeck();
const std::string source = "Root, 1, 6";
const std::size_t location = nonzero.rfind(source);
ASSERT_NE(location, std::string::npos);
nonzero.replace(location, source.size(), "Root, 1, 6, 1.");
auto boundary = MapText("nonzero-boundary", nonzero);
ASSERT_FALSE(boundary.HasValue());
EXPECT_NE(FindDiagnostic(boundary.GetStatus(), "unsupported-eigen-boundary"),
nullptr);
}
TEST(EigenDomainMapping, RejectsMixedProceduresInvalidOutputAndSurface) {
std::string mixed = EigenDeck();
mixed.insert(mixed.find("*Restart"), "*Static\n0.1, 1., 0.01, 1.\n");
auto mixed_result = MapText("mixed-procedures", mixed);
ASSERT_FALSE(mixed_result.HasValue());
EXPECT_NE(FindDiagnostic(mixed_result.GetStatus(),
"unsupported-analysis-procedure"),
nullptr);
auto wrong_output = MapText(
"wrong-output",
EigenDeck().replace(EigenDeck().find("*Output, field\n*Node Output"),
std::string{"*Output, field\n*Node Output"}.size(),
"*Output, history\n*Node Output"));
ASSERT_FALSE(wrong_output.HasValue());
EXPECT_NE(FindDiagnostic(wrong_output.GetStatus(), "unsupported-keyword"),
nullptr);
std::string surface = EigenDeck();
surface.insert(surface.find("*Material"),
"*Surface, name=Face, type=ELEMENT\n");
auto surface_result = MapText("surface", surface);
ASSERT_FALSE(surface_result.HasValue());
}
TEST(EigenDomainMapping, RejectsInvalidPreprintAndAcceptsStrictNoOp) {
const std::string preprint =
"*Preprint, echo=NO, model=NO, history=NO, contact=NO\n";
auto accepted = MapText("preprint", preprint + EigenDeck());
ASSERT_TRUE(accepted.HasValue());
const auto warning = std::find_if(accepted.Value().Warnings().begin(),
accepted.Value().Warnings().end(),
[](const fesa::Diagnostic& diagnostic) {
return diagnostic.keyword == "PREPRINT";
});
ASSERT_NE(warning, accepted.Value().Warnings().end());
EXPECT_EQ(warning->message, "mandatory-eigen-output-unchanged");
auto rejected =
MapText("bad-preprint", "*Preprint, echo=YES\n" + EigenDeck());
ASSERT_FALSE(rejected.HasValue());
EXPECT_NE(FindDiagnostic(rejected.GetStatus(), "unsupported-keyword"),
nullptr);
auto missing_parameters =
MapText("bare-preprint", "*Preprint\n" + EigenDeck());
ASSERT_FALSE(missing_parameters.HasValue());
EXPECT_NE(
FindDiagnostic(missing_parameters.GetStatus(), "unsupported-keyword"),
nullptr);
}
} // namespace
@@ -0,0 +1,125 @@
#include "fesa/io/hdf5/eigen_hdf5_results_writer.h"
#include <gtest/gtest.h>
#include <hdf5.h>
#include <filesystem>
#include <fstream>
#include <functional>
#include <iterator>
#include <limits>
#include <memory>
#include <string>
#include "fesa/analysis/linear_frequency_procedure.h"
#include "fesa/model/domain.h"
#include "io/hdf5/eigen_hdf5_self_check.h"
namespace {
fesa::ModelDefinition Definition(const std::filesystem::path& source) {
fesa::ModelDefinition definition{};
definition.source_path = source;
definition.source_content_identity = "fnv1a64:0123456789abcdef";
definition.nodes = {{{"I", 1, "1"}, {-1.0, -1.0, 0.0}, {source, 1U}},
{{"I", 2, "2"}, {1.0, -1.0, 0.0}, {source, 2U}},
{{"I", 3, "3"}, {1.0, 1.0, 0.0}, {source, 3U}},
{{"I", 4, "4"}, {-1.0, 1.0, 0.0}, {source, 4U}}};
definition.materials = {{"Steel", 12.0, 0.0, {source, 5U}}};
definition.shell_sections = {{"S", 1.0, 0U, {source, 6U}}};
definition.shell_elements = {{{"I", 1, "1"},
fesa::ShellSourceElementType::kS4,
{0U, 1U, 2U, 3U},
0U,
0U,
{source, 7U}}};
for (std::size_t node = 0U; node < 4U; ++node) {
definition.shell_node_initial_frames.push_back(
{static_cast<fesa::EntityIndex>(node),
{0.0, 0.0, 1.0},
{1.0, 0.0, 0.0},
{0.0, 1.0, 0.0}});
}
definition.densities = {{0U, 1.0, {source, 8U}}};
definition.frequency_step =
fesa::FrequencyStepDefinition{"Step-2",
"STEP-2",
{source, 9U},
{source, 10U},
{source, 11U},
1,
false,
false,
false,
false,
false,
false,
fesa::FrequencyEigensolver::kLanczos,
fesa::FrequencyNormalization::kMass,
{}};
return definition;
}
TEST(EigenHdf5ResultsWriter, WritesModalSchemaAndRejectsTamperedState) {
const auto path =
std::filesystem::temp_directory_path() / "fesa-eigen-writer-test.h5";
std::error_code ignored;
std::filesystem::remove(path, ignored);
const auto source = path.parent_path() / "fesa-eigen-writer-test.inp";
auto domain_result = fesa::Domain::Create(Definition(source));
ASSERT_TRUE(domain_result.HasValue());
fesa::Domain domain{std::move(domain_result.Value())};
fesa::EigenHdf5ResultsWriter writer;
auto execution = fesa::LinearFrequencyProcedureExecutor{}.Execute(domain);
ASSERT_TRUE(execution.HasValue());
fesa::EigenAnalysisState state = std::move(execution.Value());
ASSERT_TRUE(writer.Write(path, domain, state, {}).IsOk());
const hid_t file =
H5Fopen(path.string().c_str(), H5F_ACC_RDONLY, H5P_DEFAULT);
ASSERT_GE(file, 0);
EXPECT_GT(H5Lexists(file, "/steps/Step-1/eigen/modes", H5P_DEFAULT), 0);
EXPECT_GE(H5Fclose(file), 0);
std::ifstream original_file{path, std::ios::binary};
const std::string original{std::istreambuf_iterator<char>{original_file}, {}};
original_file.close();
const std::vector<std::function<void(fesa::EigenAnalysisState&)>> mutations{
[](auto& candidate) {
candidate.vectors(0U, 0U) = std::numeric_limits<double>::quiet_NaN();
},
[](auto& candidate) { candidate.eigenpair_checks[0].rho_b = 1e-5; },
[](auto& candidate) { candidate.eigenpair_checks[0].eigenvalue += 1.0; },
[](auto& candidate) { candidate.modes[0].cluster_id = 7U; },
[](auto& candidate) { candidate.constraint_mask[0] = 1U; },
[](auto& candidate) { candidate.nodal_reduction[0].rcond_inf = 1e-15; },
[](auto& candidate) { candidate.eta_gram = 1e-5; }};
for (const auto& mutate : mutations) {
auto candidate = state;
mutate(candidate);
EXPECT_FALSE(writer.Write(path, domain, candidate, {}).IsOk());
}
std::ifstream retained_file{path, std::ios::binary};
const std::string retained{std::istreambuf_iterator<char>{retained_file}, {}};
EXPECT_TRUE(retained == original);
retained_file.close();
ASSERT_TRUE(
fesa::hdf5_internal::SelfCheckEigenFile(path, domain, state, {}).IsOk());
const hid_t corrupt_file =
H5Fopen(path.string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT);
ASSERT_GE(corrupt_file, 0);
const hid_t nodes = H5Dopen2(corrupt_file, "/model/nodes", H5P_DEFAULT);
ASSERT_GE(nodes, 0);
const hid_t id_type = H5Tcreate(H5T_COMPOUND, sizeof(std::uint64_t));
ASSERT_GE(H5Tinsert(id_type, "internal_node_id", 0U, H5T_NATIVE_UINT64), 0);
const std::array<std::uint64_t, 4> duplicate_ids{0U, 0U, 2U, 3U};
ASSERT_GE(H5Dwrite(nodes, id_type, H5S_ALL, H5S_ALL, H5P_DEFAULT,
duplicate_ids.data()),
0);
EXPECT_GE(H5Tclose(id_type), 0);
EXPECT_GE(H5Dclose(nodes), 0);
EXPECT_GE(H5Fclose(corrupt_file), 0);
EXPECT_FALSE(
fesa::hdf5_internal::SelfCheckEigenFile(path, domain, state, {}).IsOk());
std::filesystem::remove(path, ignored);
}
} // namespace