#include "fesa/fesa.hpp" #include #include #include #include #include #include #include static_assert(std::is_same_v, "Real must remain double"); static_assert(std::is_same_v, "GlobalId must remain int64"); static_assert(std::is_same_v, "LocalIndex must remain int64"); static_assert(std::is_same_v, "EquationId must remain int64"); static_assert(std::is_same_v, "SparseIndex must remain int64"); namespace { using TestFn = std::function; struct TestCase { std::string name; TestFn fn; }; std::vector& registry() { static std::vector tests; return tests; } struct RegisterTest { RegisterTest(std::string name, TestFn fn) { registry().push_back({std::move(name), std::move(fn)}); } }; #define FESA_TEST(name) \ void name(); \ RegisterTest register_##name(#name, name); \ void name() #define FESA_CHECK(expr) \ do { \ if (!(expr)) { \ throw std::runtime_error(std::string("check failed: ") + #expr); \ } \ } while (false) #define FESA_CHECK_NEAR(actual, expected, tol) \ do { \ const auto actual_value = (actual); \ const auto expected_value = (expected); \ if (std::fabs(actual_value - expected_value) > (tol)) { \ throw std::runtime_error(std::string("near check failed: ") + #actual); \ } \ } while (false) std::string sourceRoot() { #ifdef FESA_SOURCE_DIR return FESA_SOURCE_DIR; #else return "."; #endif } std::string phase1Input() { return R"inp( *Node 1, 0, 0, 0 2, 1, 0, 0 3, 1, 1, 0 4, 0, 1, 0 *Element, type=S4, elset=EALL 1, 1, 2, 3, 4 *Nset, nset=LEFT 1, 4 *Nset, nset=RIGHT 2, 3 *Elset, elset=EALL 1 *Material, name=STEEL *Elastic 1000.0, 0.3 *Shell Section, elset=EALL, material=STEEL 0.1 *Boundary LEFT, 1, 6, 0 RIGHT, 1, 2, 0 RIGHT, 4, 6, 0 *Cload 2, 3, -1 3, 3, -1 *Step, name=Step-1 *Static *End Step )inp"; } fesa::Domain parsedPhase1Domain() { fesa::AbaqusInputParser parser; auto parsed = parser.parseString(phase1Input()); FESA_CHECK(parsed.ok()); auto diagnostics = fesa::validateDomain(parsed.domain); FESA_CHECK(!fesa::hasError(diagnostics)); return parsed.domain; } const fesa::Diagnostic* findDiagnostic(const std::vector& diagnostics, const std::string& code) { for (const auto& diagnostic : diagnostics) { if (diagnostic.code == code) { return &diagnostic; } } return nullptr; } std::size_t diagnosticCount(const std::vector& diagnostics, const std::string& code) { std::size_t count = 0; for (const auto& diagnostic : diagnostics) { if (diagnostic.code == code) { ++count; } } return count; } fesa::Domain singleElementValidationDomain() { fesa::Domain domain; domain.nodes[1] = {1, {0, 0, 0}}; domain.nodes[2] = {2, {1, 0, 0}}; domain.nodes[3] = {3, {1, 1, 0}}; domain.nodes[4] = {4, {0, 1, 0}}; domain.elements[1] = {1, fesa::ElementType::MITC4, {1, 2, 3, 4}, "EALL"}; domain.element_sets["eall"] = {"EALL", {1}}; domain.node_sets["all_nodes"] = {"ALL_NODES", {1, 2, 3, 4}}; domain.materials["mat"] = {"MAT", 1000.0, 0.3}; domain.shell_sections.push_back({"EALL", "MAT", 0.1}); domain.loads.push_back({"2", 3, -1.0}); return domain; } fesa::Domain noncontiguousDofDomain() { fesa::Domain domain; domain.nodes[30] = {30, {1, 1, 0}}; domain.nodes[10] = {10, {0, 0, 0}}; domain.nodes[40] = {40, {0, 1, 0}}; domain.nodes[20] = {20, {1, 0, 0}}; domain.elements[5] = {5, fesa::ElementType::MITC4, {10, 20, 30, 40}, "EALL"}; domain.element_sets["eall"] = {"EALL", {5}}; domain.node_sets["clamped"] = {"CLAMPED", {20}}; domain.materials["mat"] = {"MAT", 1000.0, 0.3}; domain.shell_sections.push_back({"EALL", "MAT", 0.1}); domain.boundary_conditions.push_back({"CLAMPED", 1, 6, 0.0}); domain.boundary_conditions.push_back({"30", 1, 2, 0.0}); return domain; } } // namespace FESA_TEST(core_types_and_dof_mapping_are_stable) { FESA_CHECK(sizeof(fesa::Real) == 8); FESA_CHECK(sizeof(fesa::GlobalId) == 8); FESA_CHECK(sizeof(fesa::LocalIndex) == 8); FESA_CHECK(sizeof(fesa::EquationId) == 8); FESA_CHECK(sizeof(fesa::SparseIndex) == 8); FESA_CHECK(std::numeric_limits::is_signed); FESA_CHECK(std::numeric_limits::is_signed); FESA_CHECK(std::numeric_limits::is_signed); FESA_CHECK(std::numeric_limits::is_signed); const auto dofs = fesa::allDofs(); FESA_CHECK(dofs.size() == 6); for (std::size_t i = 0; i < dofs.size(); ++i) { const int abaqus_number = static_cast(i + 1); FESA_CHECK(fesa::abaqusDofNumber(dofs[i]) == abaqus_number); FESA_CHECK(fesa::dofFromAbaqus(abaqus_number).value() == dofs[i]); FESA_CHECK(std::string(fesa::dofLabel(dofs[i])) == fesa::displacementComponentLabels()[i]); } FESA_CHECK(!fesa::dofFromAbaqus(0).has_value()); FESA_CHECK(!fesa::dofFromAbaqus(7).has_value()); } FESA_TEST(parser_accepts_phase1_subset) { fesa::AbaqusInputParser parser; auto parsed = parser.parseString(phase1Input()); FESA_CHECK(parsed.ok()); FESA_CHECK(parsed.domain.nodes.size() == 4); FESA_CHECK(parsed.domain.elements.size() == 1); FESA_CHECK(parsed.domain.node_sets.at("left").node_ids.size() == 2); FESA_CHECK(parsed.domain.element_sets.at("eall").element_ids.size() == 1); FESA_CHECK(parsed.domain.materials.at("steel").elastic_modulus == 1000.0); FESA_CHECK(parsed.domain.shell_sections.front().thickness == 0.1); FESA_CHECK(parsed.domain.boundary_conditions.size() == 3); FESA_CHECK(parsed.domain.loads.size() == 2); } FESA_TEST(parser_accepts_repeated_and_generated_sets) { const std::string text = R"inp( *Node 1, 0, 0, 0 2, 1, 0, 0 3, 1, 1, 0 4, 0, 1, 0 *Element, type=S4, elset=EALL 1, 1, 2, 3, 4 *Nset, nset=FIXED 1, 2, 2 3 *Nset, nset=FIXED, generate 3, 4, 1 *Nset, nset=LOADS, generate 2, 4, 2 *Elset, elset=EALL 1, 1 *Elset, elset=CHECK, generate 1, 5, 2 *Material, name=MAT *Elastic 2.0D5, 0.25 *Shell Section, elset=EALL, material=MAT 0.2 *Boundary FIXED, 1, 6 *Cload LOADS, 3, -2.5 *Step, name=Step-A, nlgeom=NO *Static *End Step )inp"; fesa::AbaqusInputParser parser; auto parsed = parser.parseString(text); FESA_CHECK(parsed.ok()); FESA_CHECK(parsed.domain.node_sets.at("fixed").node_ids == std::vector({1, 2, 3, 4})); FESA_CHECK(parsed.domain.node_sets.at("loads").node_ids == std::vector({2, 4})); FESA_CHECK(parsed.domain.element_sets.at("eall").element_ids == std::vector({1})); FESA_CHECK(parsed.domain.element_sets.at("check").element_ids == std::vector({1, 3, 5})); FESA_CHECK(parsed.domain.materials.at("mat").elastic_modulus == 2.0e5); FESA_CHECK(parsed.domain.steps.front().name == "Step-A"); } FESA_TEST(parser_accepts_keyword_line_continuation) { const std::string text = R"inp( *Node 1, 0, 0, 0 2, 1, 0, 0 3, 1, 1, 0 4, 0, 1, 0 *Element, type=S4, elset=EALL 1, 1, 2, 3, 4 *Nset, nset=FIXED, generate 1, 4, 3 *Elset, elset=EALL 1 *Material, name=MAT *Elastic 2.0e5, 0.25 *Shell Section, elset=EALL, material=MAT 0.2 *Boundary FIXED, 1, 6 *Cload 2, 3, -1.0 *Step, name=Step-1 *Static *End Step )inp"; fesa::AbaqusInputParser parser; auto parsed = parser.parseString(text); FESA_CHECK(parsed.ok()); FESA_CHECK(parsed.domain.elements.at(1).source_elset == "EALL"); FESA_CHECK(parsed.domain.node_sets.at("fixed").node_ids == std::vector({1, 4})); FESA_CHECK(parsed.domain.materials.count("mat") == 1); FESA_CHECK(parsed.domain.shell_sections.front().material == "MAT"); } FESA_TEST(parser_rejects_unsupported_features) { const std::string text = R"inp( *Part, name=P1 *Assembly, name=A1 *Instance, name=I1, part=P1 *Include, input=other.inp *Node 1, 0, 0, 0 *Element, type=S4R 1, 1, 2, 3, 4 *Density 7850 *Step, nlgeom=YES *End Step )inp"; fesa::AbaqusInputParser parser; auto parsed = parser.parseString(text); FESA_CHECK(!parsed.ok()); FESA_CHECK(diagnosticCount(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-KEYWORD") >= 4); FESA_CHECK(fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-KEYWORD")); FESA_CHECK(fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-ELEMENT")); FESA_CHECK(fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-NLGEOM")); } FESA_TEST(parser_rejects_unsupported_keyword_parameters_and_modes) { const std::string text = R"inp( *Node, input=nodes.csv 1, 0, 0, 0 2, 1, 0, 0 3, 1, 1, 0 4, 0, 1, 0 *Element, type=S4, elset=EALL, orientation=OR1 1, 1, 2, 3, 4 *Nset, nset=FIXED, unsorted 1, 4 *Material, name=MAT, description=bad *Elastic, type=ENGINEERING CONSTANTS 2.0e5, 0.25 *Shell Section, elset=EALL, material=MAT, offset=SPOS 0.2, 5 *Boundary, op=NEW FIXED, 1, 6 *Cload, amplitude=A1 2, 3, -1.0 *Step, name=Step-1, inc=100 *Static, stabilize *End Step )inp"; fesa::AbaqusInputParser parser; auto parsed = parser.parseString(text, "unsupported_modes.inp"); FESA_CHECK(!parsed.ok()); FESA_CHECK(diagnosticCount(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-PARAMETER") >= 8); FESA_CHECK(fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-SHELL-SECTION-UNSUPPORTED")); } FESA_TEST(parser_diagnostics_include_file_line_and_keyword) { const std::string text = R"inp( *Node 1, bad, 0, 0 *Boundary FIXED, 7, 7 )inp"; fesa::AbaqusInputParser parser; auto parsed = parser.parseString(text, "malformed.inp"); FESA_CHECK(!parsed.ok()); const fesa::Diagnostic* node = findDiagnostic(parsed.diagnostics, "FESA-PARSE-NODE-NUMERIC"); FESA_CHECK(node != nullptr); FESA_CHECK(node->source.file == "malformed.inp"); FESA_CHECK(node->source.line == 3); FESA_CHECK(node->source.keyword == "node"); const fesa::Diagnostic* boundary = findDiagnostic(parsed.diagnostics, "FESA-PARSE-BOUNDARY-DOF"); FESA_CHECK(boundary != nullptr); FESA_CHECK(boundary->source.file == "malformed.inp"); FESA_CHECK(boundary->source.line == 5); FESA_CHECK(boundary->source.keyword == "boundary"); } FESA_TEST(quad01_reference_input_remains_unsupported) { fesa::AbaqusInputParser parser; auto parsed = parser.parseFile(sourceRoot() + "/references/quad_01.inp"); FESA_CHECK(!parsed.ok()); FESA_CHECK(fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-KEYWORD") || fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-ELEMENT")); } FESA_TEST(quad02_original_reference_input_remains_unsupported) { fesa::AbaqusInputParser parser; auto parsed = parser.parseFile(sourceRoot() + "/references/quad_02.inp"); FESA_CHECK(!parsed.ok()); FESA_CHECK(fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-KEYWORD")); } FESA_TEST(quad02_phase1_normalized_input_uses_supported_subset) { fesa::AbaqusInputParser parser; auto parsed = parser.parseFile(sourceRoot() + "/references/quad_02_phase1.inp"); FESA_CHECK(parsed.ok()); FESA_CHECK(parsed.domain.nodes.size() == 121); FESA_CHECK(parsed.domain.elements.size() == 100); FESA_CHECK(parsed.domain.node_sets.at("fixed_boundary").node_ids.size() == 40); FESA_CHECK(parsed.domain.node_sets.at("load_node").node_ids.size() == 1); FESA_CHECK(parsed.domain.element_sets.at("all_elements").element_ids.size() == 100); FESA_CHECK(parsed.domain.materials.at("material_1").elastic_modulus == 7.0e10); FESA_CHECK(parsed.domain.shell_sections.front().thickness == 1.0); } FESA_TEST(domain_validation_reports_missing_property_and_targets) { fesa::Domain domain; domain.nodes[1] = {1, {0, 0, 0}}; domain.nodes[2] = {2, {1, 0, 0}}; domain.nodes[3] = {3, {1, 1, 0}}; domain.nodes[4] = {4, {0, 1, 0}}; domain.elements[1] = {1, fesa::ElementType::MITC4, {1, 2, 3, 99}, ""}; domain.shell_sections.push_back({"MISSING_ELSET", "MISSING_MAT", 0.1}); domain.loads.push_back({"MISSING", 3, 1.0}); auto diagnostics = fesa::validateDomain(domain); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-ELEMENT-MISSING-NODE")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-MISSING-PROPERTY")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-MISSING-ELSET")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-MISSING-MATERIAL")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-MISSING-NSET")); for (const auto& diagnostic : diagnostics) { FESA_CHECK(!diagnostic.code.empty()); FESA_CHECK(!diagnostic.message.empty()); FESA_CHECK(!diagnostic.source.keyword.empty()); } } FESA_TEST(domain_validation_reports_missing_sets_and_set_members) { auto domain = singleElementValidationDomain(); domain.shell_sections.clear(); domain.shell_sections.push_back({"MISSING_ELSET", "MISSING_MAT", 0.1}); domain.node_sets["bad_nodes"] = {"BAD_NODES", {1, 99}}; domain.element_sets["bad_elements"] = {"BAD_ELEMENTS", {1, 77}}; domain.boundary_conditions.push_back({"MISSING_BOUNDARY_SET", 1, 6, 0.0}); domain.boundary_conditions.push_back({"BAD_NODES", 1, 1, 0.0}); domain.loads.push_back({"MISSING_LOAD_SET", 3, 1.0}); domain.loads.push_back({"BAD_NODES", 3, 1.0}); auto diagnostics = fesa::validateDomain(domain); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-MISSING-ELSET")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-MISSING-MATERIAL")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-MISSING-NSET")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-NSET-MISSING-NODE")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-ELSET-MISSING-ELEMENT")); const auto* missing_set = findDiagnostic(diagnostics, "FESA-VALIDATION-MISSING-NSET"); FESA_CHECK(missing_set != nullptr); FESA_CHECK(missing_set->message.find("MISSING_BOUNDARY_SET") != std::string::npos || missing_set->message.find("MISSING_LOAD_SET") != std::string::npos); const auto* missing_node = findDiagnostic(diagnostics, "FESA-VALIDATION-NSET-MISSING-NODE"); FESA_CHECK(missing_node != nullptr); FESA_CHECK(missing_node->message.find("BAD_NODES") != std::string::npos); FESA_CHECK(missing_node->message.find("99") != std::string::npos); } FESA_TEST(domain_validation_reports_nonpositive_thickness_and_invalid_dofs) { auto domain = singleElementValidationDomain(); domain.shell_sections.front().thickness = 0.0; domain.boundary_conditions.push_back({"ALL_NODES", 0, 1, 0.0}); domain.loads.push_back({"2", 7, 1.0}); auto diagnostics = fesa::validateDomain(domain); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-NONPOSITIVE-THICKNESS")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-BOUNDARY-DOF")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-VALIDATION-CLOAD-DOF")); const auto* thickness = findDiagnostic(diagnostics, "FESA-VALIDATION-NONPOSITIVE-THICKNESS"); FESA_CHECK(thickness != nullptr); FESA_CHECK(thickness->message.find("EALL") != std::string::npos); const auto* load_dof = findDiagnostic(diagnostics, "FESA-VALIDATION-CLOAD-DOF"); FESA_CHECK(load_dof != nullptr); FESA_CHECK(load_dof->message.find("DOF 7") != std::string::npos); } FESA_TEST(domain_validation_reports_no_active_elements_and_missing_load) { fesa::Domain domain; domain.nodes[1] = {1, {0, 0, 0}}; auto diagnostics = fesa::validateDomain(domain); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-SINGULAR-NO-ACTIVE-ELEMENTS")); FESA_CHECK(fesa::containsDiagnostic(diagnostics, "FESA-SINGULAR-NO-NONZERO-LOAD")); for (const auto& diagnostic : diagnostics) { FESA_CHECK(diagnostic.code.find("MESH") == std::string::npos); } } FESA_TEST(domain_validation_reports_no_free_dofs) { auto domain = singleElementValidationDomain(); domain.boundary_conditions.push_back({"ALL_NODES", 1, 6, 0.0}); auto diagnostics = fesa::validateDomain(domain); const auto* no_free = findDiagnostic(diagnostics, "FESA-SINGULAR-NO-FREE-DOFS"); FESA_CHECK(no_free != nullptr); FESA_CHECK(no_free->source.keyword == "dof"); FESA_CHECK(no_free->message.find("No free DOFs") != std::string::npos); } FESA_TEST(domain_validation_reports_free_untouched_dofs_for_isolated_nodes) { auto domain = singleElementValidationDomain(); domain.nodes[99] = {99, {10, 0, 0}}; domain.boundary_conditions.push_back({"ALL_NODES", 1, 6, 0.0}); auto diagnostics = fesa::validateDomain(domain); const auto* untouched = findDiagnostic(diagnostics, "FESA-SINGULAR-DOF-UNTOUCHED"); FESA_CHECK(untouched != nullptr); FESA_CHECK(untouched->source.keyword == "dof"); FESA_CHECK(untouched->message.find("Node 99") != std::string::npos); FESA_CHECK(untouched->message.find("UX") != std::string::npos); } FESA_TEST(domain_validation_reports_weak_drilling_dof_smoke) { auto domain = singleElementValidationDomain(); domain.boundary_conditions.push_back({"ALL_NODES", 1, 5, 0.0}); auto diagnostics = fesa::validateDomain(domain); const auto* weak_drilling = findDiagnostic(diagnostics, "FESA-SINGULAR-WEAK-DRILLING-DOF"); FESA_CHECK(weak_drilling != nullptr); FESA_CHECK(weak_drilling->severity == fesa::Severity::Warning); FESA_CHECK(weak_drilling->source.keyword == "dof"); FESA_CHECK(weak_drilling->message.find("RZ") != std::string::npos); } FESA_TEST(dof_manager_owns_equation_numbering_and_reconstruction) { auto domain = parsedPhase1Domain(); fesa::DofManager dofs(domain); FESA_CHECK(dofs.fullDofCount() == 24); FESA_CHECK(dofs.freeDofCount() == 2); FESA_CHECK(dofs.isConstrained(1, fesa::Dof::UX)); FESA_CHECK(dofs.equation(2, fesa::Dof::UZ) == 0); FESA_CHECK(dofs.equation(3, fesa::Dof::UZ) == 1); auto full = dofs.reconstructFullVector({-0.1, -0.2}); FESA_CHECK_NEAR(full[static_cast(dofs.fullIndex(2, fesa::Dof::UZ))], -0.1, 1.0e-15); FESA_CHECK_NEAR(full[static_cast(dofs.fullIndex(1, fesa::Dof::UX))], 0.0, 1.0e-15); } FESA_TEST(dof_manager_preserves_global_order_for_noncontiguous_nodes) { auto domain = noncontiguousDofDomain(); fesa::DofManager dofs(domain); FESA_CHECK(dofs.nodeIds() == std::vector({10, 20, 30, 40})); FESA_CHECK(dofs.fullDofCount() == 24); for (std::size_t node_offset = 0; node_offset < dofs.nodeIds().size(); ++node_offset) { const fesa::GlobalId node_id = dofs.nodeIds()[node_offset]; for (fesa::Dof dof : fesa::allDofs()) { const fesa::LocalIndex expected = static_cast(6 * node_offset + fesa::dofIndex(dof)); FESA_CHECK(dofs.fullIndex(node_id, dof) == expected); const auto address = dofs.fullDof(expected); FESA_CHECK(address.node_id == node_id); FESA_CHECK(address.dof == dof); } } } FESA_TEST(dof_manager_partitions_constraints_and_equations_deterministically) { auto domain = noncontiguousDofDomain(); fesa::DofManager dofs(domain); FESA_CHECK(dofs.constrainedDofCount() == 8); FESA_CHECK(dofs.freeDofCount() == 16); FESA_CHECK(dofs.constrainedFullIndices() == std::vector({6, 7, 8, 9, 10, 11, 12, 13})); FESA_CHECK(dofs.freeFullIndices() == std::vector({0, 1, 2, 3, 4, 5, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23})); FESA_CHECK(dofs.equation(10, fesa::Dof::UX) == 0); FESA_CHECK(dofs.equation(20, fesa::Dof::RZ) == -1); FESA_CHECK(dofs.equation(30, fesa::Dof::UX) == -1); FESA_CHECK(dofs.equation(30, fesa::Dof::UZ) == 6); FESA_CHECK(dofs.equation(40, fesa::Dof::RZ) == 15); } FESA_TEST(dof_manager_reduces_and_reconstructs_full_vectors) { auto domain = noncontiguousDofDomain(); fesa::DofManager dofs(domain); std::vector full(static_cast(dofs.fullDofCount()), 0.0); for (std::size_t i = 0; i < full.size(); ++i) { full[i] = static_cast(100 + i); } auto reduced = dofs.reduceFullVector(full); FESA_CHECK(reduced.size() == static_cast(dofs.freeDofCount())); FESA_CHECK_NEAR(reduced[0], full[0], 1.0e-15); FESA_CHECK_NEAR(reduced[6], full[14], 1.0e-15); FESA_CHECK_NEAR(reduced.back(), full[23], 1.0e-15); auto reconstructed = dofs.reconstructFullVector(reduced); for (fesa::LocalIndex full_index = 0; full_index < dofs.fullDofCount(); ++full_index) { const auto address = dofs.fullDof(full_index); if (dofs.isConstrained(address.node_id, address.dof)) { FESA_CHECK_NEAR(reconstructed[static_cast(full_index)], 0.0, 1.0e-15); } else { FESA_CHECK_NEAR(reconstructed[static_cast(full_index)], full[static_cast(full_index)], 1.0e-15); } } } FESA_TEST(dof_manager_provides_element_sparse_connectivity_inputs) { auto domain = parsedPhase1Domain(); fesa::DofManager dofs(domain); const auto& element = domain.elements.at(1); auto full_indices = dofs.elementFullDofIndices(element); auto equation_ids = dofs.elementEquationIds(element); for (fesa::LocalIndex i = 0; i < 24; ++i) { FESA_CHECK(full_indices[static_cast(i)] == i); } for (fesa::LocalIndex i = 0; i < 6; ++i) { FESA_CHECK(equation_ids[static_cast(i)] == -1); FESA_CHECK(equation_ids[static_cast(18 + i)] == -1); } FESA_CHECK(equation_ids[static_cast(6 + fesa::dofIndex(fesa::Dof::UZ))] == 0); FESA_CHECK(equation_ids[static_cast(12 + fesa::dofIndex(fesa::Dof::UZ))] == 1); FESA_CHECK(equation_ids[static_cast(6 + fesa::dofIndex(fesa::Dof::UX))] == -1); } FESA_TEST(full_vector_reaction_recovery_uses_full_system_quantities) { auto domain = noncontiguousDofDomain(); fesa::DofManager dofs(domain); fesa::DenseMatrix k_full(dofs.fullDofCount(), dofs.fullDofCount()); std::vector u_full(static_cast(dofs.fullDofCount()), 0.0); std::vector f_full(static_cast(dofs.fullDofCount()), 0.0); const fesa::LocalIndex support_ux = dofs.fullIndex(20, fesa::Dof::UX); const fesa::LocalIndex free_uz = dofs.fullIndex(30, fesa::Dof::UZ); k_full(support_ux, support_ux) = 10.0; k_full(support_ux, free_uz) = 2.0; k_full(free_uz, support_ux) = 2.0; k_full(free_uz, free_uz) = 5.0; u_full[static_cast(free_uz)] = 3.0; f_full[static_cast(support_ux)] = 1.0; f_full[static_cast(free_uz)] = 7.0; auto reaction = fesa::recoverFullReaction(k_full, u_full, f_full); FESA_CHECK_NEAR(reaction[static_cast(support_ux)], 5.0, 1.0e-15); FESA_CHECK_NEAR(reaction[static_cast(free_uz)], 8.0, 1.0e-15); } FESA_TEST(gaussian_solver_solves_and_diagnoses_singular_systems) { fesa::DenseMatrix a(2, 2); a(0, 0) = 2.0; a(0, 1) = 1.0; a(1, 0) = 1.0; a(1, 1) = 3.0; fesa::GaussianEliminationSolver solver; auto solved = solver.solve(a, {1.0, 2.0}); FESA_CHECK(solved.ok()); FESA_CHECK_NEAR(solved.x[0], 0.2, 1.0e-12); FESA_CHECK_NEAR(solved.x[1], 0.6, 1.0e-12); fesa::DenseMatrix singular(2, 2); singular(0, 0) = 1.0; singular(0, 1) = 2.0; singular(1, 0) = 2.0; singular(1, 1) = 4.0; auto failed = solver.solve(singular, {1.0, 2.0}); FESA_CHECK(!failed.ok()); FESA_CHECK(fesa::containsDiagnostic(failed.diagnostics, "FESA-SINGULAR-SOLVER")); } FESA_TEST(results_writer_uses_step_frame_fields_for_u_and_rf) { auto domain = parsedPhase1Domain(); fesa::DofManager dofs(domain); std::vector u(static_cast(dofs.fullDofCount()), 0.0); std::vector rf(static_cast(dofs.fullDofCount()), 0.0); u[static_cast(dofs.fullIndex(2, fesa::Dof::UZ))] = -0.1; rf[static_cast(dofs.fullIndex(1, fesa::Dof::UZ))] = 1.0; fesa::InMemoryResultsWriter writer; writer.writeLinearStatic(domain, dofs, u, rf); const auto& result = writer.result(); FESA_CHECK(result.schema_name == "FESA_RESULTS"); FESA_CHECK(result.schema_version == 1); FESA_CHECK(result.solver_name == "FESA"); FESA_CHECK(result.dof_convention == "UX,UY,UZ,RX,RY,RZ"); FESA_CHECK(result.sign_convention == "Abaqus-compatible"); FESA_CHECK(result.precision == "double"); FESA_CHECK(result.index_type == "int64"); FESA_CHECK(result.steps.size() == 1); FESA_CHECK(result.steps[0].name == "Step-1"); FESA_CHECK(result.steps[0].frames.size() == 1); const auto& frame = result.steps[0].frames[0]; FESA_CHECK(frame.frame_id == 0); FESA_CHECK(frame.increment == 1); FESA_CHECK(frame.iteration == 0); FESA_CHECK(frame.converged); FESA_CHECK_NEAR(frame.step_time, 1.0, 1.0e-15); FESA_CHECK_NEAR(frame.total_time, 1.0, 1.0e-15); FESA_CHECK(frame.field_outputs.count("U") == 1); FESA_CHECK(frame.field_outputs.count("RF") == 1); const auto& u_field = frame.field_outputs.at("U"); const auto& rf_field = frame.field_outputs.at("RF"); FESA_CHECK(u_field.name == "U"); FESA_CHECK(u_field.position == "NODAL"); FESA_CHECK(u_field.entity_type == "node"); FESA_CHECK(u_field.basis == "GLOBAL"); FESA_CHECK(u_field.component_labels == fesa::displacementComponentLabels()); FESA_CHECK(rf_field.name == "RF"); FESA_CHECK(rf_field.position == "NODAL"); FESA_CHECK(rf_field.entity_type == "node"); FESA_CHECK(rf_field.basis == "GLOBAL"); FESA_CHECK(rf_field.component_labels == fesa::reactionComponentLabels()); FESA_CHECK(u_field.component_labels[2] == "UZ"); FESA_CHECK(rf_field.component_labels[2] == "RFZ"); FESA_CHECK(u_field.entity_ids.size() == domain.nodes.size()); FESA_CHECK(rf_field.entity_ids.size() == domain.nodes.size()); } FESA_TEST(displacement_csv_loader_accepts_quad01_format) { auto table = fesa::loadDisplacementCsv(sourceRoot() + "/references/quad_01_displacements.csv"); FESA_CHECK(!fesa::hasError(table.diagnostics)); FESA_CHECK(table.rows.size() == 121); FESA_CHECK(table.rows.count(1) == 1); } FESA_TEST(displacement_csv_loader_accepts_quad02_format) { auto table = fesa::loadDisplacementCsv(sourceRoot() + "/references/quad_02_displacements.csv"); FESA_CHECK(!fesa::hasError(table.diagnostics)); FESA_CHECK(table.rows.size() == 121); FESA_CHECK(table.rows.count(2) == 1); FESA_CHECK(table.rows.at(2).values[2] < 0.0); } FESA_TEST(displacement_csv_loader_reports_required_header_errors) { auto table = fesa::loadDisplacementCsvFromString("Node Label,U-U1,U-U2,U-U3,UR-UR1,UR-UR2\n" "1,0,0,0,0,0\n", "missing-header.csv"); FESA_CHECK(fesa::containsDiagnostic(table.diagnostics, "FESA-CSV-MISSING-COLUMN")); } FESA_TEST(displacement_csv_loader_reports_duplicate_node_rows) { auto table = fesa::loadDisplacementCsvFromString("Node Label,U-U1,U-U2,U-U3,UR-UR1,UR-UR2,UR-UR3\n" "1,0,0,0,0,0,0\n" "1,0,0,0,0,0,0\n", "duplicate-node.csv"); FESA_CHECK(fesa::containsDiagnostic(table.diagnostics, "FESA-CSV-DUPLICATE-NODE")); } FESA_TEST(displacement_csv_loader_reports_missing_and_non_numeric_node_rows) { auto table = fesa::loadDisplacementCsvFromString("Node Label,U-U1,U-U2,U-U3,UR-UR1,UR-UR2,UR-UR3\n" ",0,0,0,0,0,0\n" "two,0,0,0,0,0,0\n" "3,0,not-a-number,0,0,0,0\n", "invalid-node-row.csv"); FESA_CHECK(diagnosticCount(table.diagnostics, "FESA-CSV-NODE") == 2); FESA_CHECK(fesa::containsDiagnostic(table.diagnostics, "FESA-CSV-NUMERIC")); } FESA_TEST(displacement_comparator_matches_by_node_id_not_row_order) { fesa::FieldOutput actual; actual.name = "U"; actual.position = "NODAL"; actual.entity_type = "node"; actual.basis = "GLOBAL"; actual.entity_ids = {2, 1}; actual.component_labels = fesa::displacementComponentLabels(); actual.values = {{{2, 0, 0, 0, 0, 0}}, {{1, 0, 0, 0, 0, 0}}}; fesa::CsvDisplacementTable expected; expected.rows[1] = {1, {1, 0, 0, 0, 0, 0}}; expected.rows[2] = {2, {2, 0, 0, 0, 0, 0}}; auto compared = fesa::compareDisplacements(actual, expected, {1.0e-12, 1.0e-12, 1.0}); FESA_CHECK(compared.pass); } FESA_TEST(displacement_comparator_uses_absolute_and_relative_tolerances) { fesa::FieldOutput actual; actual.name = "U"; actual.position = "NODAL"; actual.entity_type = "node"; actual.basis = "GLOBAL"; actual.entity_ids = {10}; actual.component_labels = fesa::displacementComponentLabels(); actual.values = {{{5.0e-7, 100.0005, 0, 0, 0, 0}}}; fesa::CsvDisplacementTable expected; expected.rows[10] = {10, {0.0, 100.0, 0, 0, 0, 0}}; auto loose = fesa::compareDisplacements(actual, expected, {1.0e-6, 1.0e-5, 1.0}); FESA_CHECK(loose.pass); FESA_CHECK_NEAR(loose.max_abs_error, 5.0e-4, 1.0e-12); auto strict = fesa::compareDisplacements(actual, expected, {1.0e-8, 1.0e-8, 1.0}); FESA_CHECK(!strict.pass); FESA_CHECK(fesa::containsDiagnostic(strict.diagnostics, "FESA-COMPARE-TOLERANCE")); } FESA_TEST(displacement_comparator_rejects_wrong_component_labels_and_missing_nodes) { fesa::FieldOutput actual; actual.name = "U"; actual.position = "NODAL"; actual.entity_type = "node"; actual.basis = "GLOBAL"; actual.entity_ids = {1}; actual.component_labels = fesa::reactionComponentLabels(); actual.values = {{{0, 0, 0, 0, 0, 0}}}; fesa::CsvDisplacementTable expected; expected.rows[2] = {2, {0, 0, 0, 0, 0, 0}}; auto compared = fesa::compareDisplacements(actual, expected, {1.0e-12, 1.0e-12, 1.0}); FESA_CHECK(!compared.pass); FESA_CHECK(fesa::containsDiagnostic(compared.diagnostics, "FESA-COMPARE-COMPONENT-LABELS")); FESA_CHECK(fesa::containsDiagnostic(compared.diagnostics, "FESA-COMPARE-MISSING-ACTUAL")); } FESA_TEST(displacement_comparator_reports_duplicate_actual_nodes) { fesa::FieldOutput actual; actual.name = "U"; actual.position = "NODAL"; actual.entity_type = "node"; actual.basis = "GLOBAL"; actual.entity_ids = {1, 1}; actual.component_labels = fesa::displacementComponentLabels(); actual.values = {{{0, 0, 0, 0, 0, 0}}, {{0, 0, 0, 0, 0, 0}}}; fesa::CsvDisplacementTable expected; expected.rows[1] = {1, {0, 0, 0, 0, 0, 0}}; auto compared = fesa::compareDisplacements(actual, expected, {1.0e-12, 1.0e-12, 1.0}); FESA_CHECK(!compared.pass); FESA_CHECK(fesa::containsDiagnostic(compared.diagnostics, "FESA-COMPARE-DUPLICATE-ACTUAL")); } FESA_TEST(mitc4_shape_functions_and_stiffness_baseline) { auto shape = fesa::shapeFunctions(0.25, -0.5); const fesa::Real sum = shape.n[0] + shape.n[1] + shape.n[2] + shape.n[3]; FESA_CHECK_NEAR(sum, 1.0, 1.0e-15); const std::array coords = {{{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}}}; fesa::MITC4ElementKernel kernel; auto k = kernel.stiffness(coords, 1000.0, 0.3, 0.1); FESA_CHECK(k.rows() == 24); FESA_CHECK(k.cols() == 24); for (fesa::LocalIndex i = 0; i < 24; ++i) { for (fesa::LocalIndex j = 0; j < 24; ++j) { FESA_CHECK_NEAR(k(i, j), k(j, i), 1.0e-8); } } std::vector uniform_translation(24, 0.0); for (int node = 0; node < 4; ++node) { uniform_translation[static_cast(6 * node + 0)] = 1.0; } auto internal = k.multiply(uniform_translation); fesa::Real norm = 0.0; for (auto value : internal) { norm += std::fabs(value); } FESA_CHECK(norm < 1.0e-8); } FESA_TEST(linear_static_analysis_solves_u_and_recovers_full_vector_rf) { auto domain = parsedPhase1Domain(); fesa::LinearStaticAnalysis analysis; auto result = analysis.run(domain); FESA_CHECK(result.ok()); FESA_CHECK(result.state.converged); FESA_CHECK(result.result_file.steps.size() == 1); const auto& frame = result.result_file.steps[0].frames[0]; FESA_CHECK(frame.field_outputs.count("U") == 1); FESA_CHECK(frame.field_outputs.count("RF") == 1); fesa::Real total_rf_z = 0.0; fesa::DofManager dofs(domain); for (auto node_id : dofs.nodeIds()) { total_rf_z += result.state.reaction_full[static_cast(dofs.fullIndex(node_id, fesa::Dof::UZ))]; } FESA_CHECK_NEAR(total_rf_z, 2.0, 1.0e-8); } int main() { int failed = 0; for (const auto& test : registry()) { try { test.fn(); std::cout << "[PASS] " << test.name << '\n'; } catch (const std::exception& error) { ++failed; std::cerr << "[FAIL] " << test.name << ": " << error.what() << '\n'; } } if (failed != 0) { std::cerr << failed << " test(s) failed\n"; return 1; } std::cout << registry().size() << " test(s) passed\n"; return 0; }