From 154f8ef4af7086257fb4c028f4c91d30f219ad1a Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Thu, 30 Jul 2026 18:10:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(domain-and-input-skeleton):=20step=203=20?= =?UTF-8?q?=E2=80=94=20active-instance-domain-normalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 1 + include/fesa/io/abaqus/semantic_mapper.hpp | 10 + src/fesa/io/abaqus/semantic_mapper.cpp | 684 ++++++++++++++++++ tests/CMakeLists.txt | 30 + .../io/minimal_deck_to_domain_test.cpp | 262 +++++++ 5 files changed, 987 insertions(+) create mode 100644 include/fesa/io/abaqus/semantic_mapper.hpp create mode 100644 src/fesa/io/abaqus/semantic_mapper.cpp create mode 100644 tests/integration/io/minimal_deck_to_domain_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c31d3a..29e37f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,7 @@ include(cmake/FesaDependencies.cmake) add_library(fesa_core STATIC src/fesa/core/version.cpp src/fesa/io/abaqus/parser.cpp + src/fesa/io/abaqus/semantic_mapper.cpp src/fesa/model/domain.cpp src/fesa/model/domain_builder.cpp ) diff --git a/include/fesa/io/abaqus/semantic_mapper.hpp b/include/fesa/io/abaqus/semantic_mapper.hpp new file mode 100644 index 0000000..ce28c55 --- /dev/null +++ b/include/fesa/io/abaqus/semantic_mapper.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +namespace fesa { + +[[nodiscard]] DomainBuildResult map_deck_to_domain(const ParsedDeck& deck); + +} // namespace fesa diff --git a/src/fesa/io/abaqus/semantic_mapper.cpp b/src/fesa/io/abaqus/semantic_mapper.cpp new file mode 100644 index 0000000..b4ebb9b --- /dev/null +++ b/src/fesa/io/abaqus/semantic_mapper.cpp @@ -0,0 +1,684 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { +namespace { + +using LabelMap = std::map; +using ElementLabelMap = std::map; +using RawSetMap = std::map, std::less<>>; + +const std::string* parameter( + const DeckRecord& record, + const std::string_view name) { + const auto found = record.parameters.find(name); + return found == record.parameters.end() ? nullptr : &found->second; +} + +bool is_mesh_record(const DeckRecord& record) { + return record.keyword == "NODE" || record.keyword == "ELEMENT"; +} + +class DeckMapper final { +public: + explicit DeckMapper(const ParsedDeck& deck) : deck_{deck} {} + + [[nodiscard]] DomainBuildResult map() { + if (!select_active_scope()) { + return failure(); + } + + collect_materials(); + collect_nodes(); + collect_raw_sets(*active_records_, false); + if (active_assembly_ != nullptr) { + collect_raw_sets(active_assembly_->records, true); + } + collect_sections(); + collect_elements(); + emit_sets(); + collect_step(); + + if (!diagnostics_.empty()) { + return failure(); + } + return std::move(builder_).build(); + } + +private: + struct SectionAssignment final { + MaterialId material; + SectionId section; + }; + + [[nodiscard]] DomainBuildResult failure() { + return {std::nullopt, std::move(diagnostics_)}; + } + + void add_error( + std::string code, + std::string message, + const SourceLocation& source) { + diagnostics_.push_back({ + DiagnosticStage::semantic, + Severity::error, + std::move(code), + std::move(message), + source, + }); + } + + const std::string* require_parameter( + const DeckRecord& record, + const std::string_view name) { + const std::string* value = parameter(record, name); + if (value == nullptr || value->empty()) { + add_error( + "abaqus.semantic.missing_parameter", + "*" + record.keyword + " requires parameter " + + std::string{name} + ".", + record.source); + return nullptr; + } + return value; + } + + std::optional parse_label( + const std::string_view text, + const DeckRecord& record, + const std::string_view purpose) { + std::int64_t value = 0; + const auto parsed = + std::from_chars(text.data(), text.data() + text.size(), value); + if (parsed.ec != std::errc{} || + parsed.ptr != text.data() + text.size() || value <= 0) { + add_error( + "abaqus.semantic.invalid_label", + "Invalid " + std::string{purpose} + " label '" + + std::string{text} + "'.", + record.source); + return std::nullopt; + } + return value; + } + + std::optional parse_dof( + const std::string_view text, + const DeckRecord& record) { + const std::optional value = + parse_label(text, record, "degree-of-freedom"); + if (!value.has_value()) { + return std::nullopt; + } + if (*value > 6) { + add_error( + "abaqus.semantic.invalid_dof", + "Phase 1 supports only degrees of freedom 1 through 6.", + record.source); + return std::nullopt; + } + return static_cast(*value); + } + + std::optional parse_real( + const std::string_view text, + const DeckRecord& record, + const std::string_view purpose) { + double value = 0.0; + const auto parsed = std::from_chars( + text.data(), + text.data() + text.size(), + value, + std::chars_format::general); + if (parsed.ec != std::errc{} || + parsed.ptr != text.data() + text.size()) { + add_error( + "abaqus.semantic.invalid_number", + "Invalid " + std::string{purpose} + " value '" + + std::string{text} + "'.", + record.source); + return std::nullopt; + } + return value; + } + + bool select_active_scope() { + const bool has_hierarchical_input = + !deck_.parts.empty() || deck_.assembly.has_value(); + if (!has_hierarchical_input) { + active_records_ = &deck_.global_records; + return true; + } + + if (std::ranges::any_of(deck_.global_records, is_mesh_record)) { + add_error( + "abaqus.semantic.mixed_mesh_organization", + "Flat mesh records cannot be mixed with Part/Assembly input.", + std::ranges::find_if( + deck_.global_records, is_mesh_record)->source); + return false; + } + if (!deck_.assembly.has_value()) { + const SourceLocation source = + deck_.parts.empty() ? SourceLocation{} : deck_.parts[0].source; + add_error( + "abaqus.semantic.assembly_count", + "Hierarchical Phase 1 input requires exactly one Assembly.", + source); + return false; + } + + active_assembly_ = &*deck_.assembly; + if (active_assembly_->instances.size() != 1U) { + add_error( + "abaqus.semantic.instance_count", + "Phase 1 requires exactly one Instance.", + active_assembly_->source); + return false; + } + + const ParsedInstance& instance = active_assembly_->instances.front(); + if (!instance.transform_data.empty()) { + add_error( + "abaqus.semantic.instance_transform", + "Instance translation and rotation data are unsupported.", + instance.source); + return false; + } + + const auto part = std::ranges::find( + deck_.parts, instance.part_name, &ParsedPart::name); + if (part == deck_.parts.end()) { + add_error( + "abaqus.semantic.missing_part", + "Instance '" + instance.name + "' references missing Part '" + + instance.part_name + "'.", + instance.source); + return false; + } + + active_records_ = &part->records; + part_name_ = part->name; + instance_name_ = instance.name; + return true; + } + + void collect_materials() { + const std::string* current_name = nullptr; + for (const DeckRecord& record : deck_.global_records) { + if (record.keyword == "MATERIAL") { + current_name = require_parameter(record, "NAME"); + continue; + } + if (record.keyword != "ELASTIC") { + continue; + } + if (current_name == nullptr) { + add_error( + "abaqus.semantic.elastic_without_material", + "*ELASTIC requires a preceding *MATERIAL.", + record.source); + continue; + } + if (record.data.empty() || record.data[0].size() < 2U) { + add_error( + "abaqus.semantic.invalid_elastic_data", + "*ELASTIC requires Young's modulus and Poisson ratio.", + record.source); + continue; + } + + const auto young = + parse_real(record.data[0][0], record, "Young's modulus"); + const auto poisson = + parse_real(record.data[0][1], record, "Poisson ratio"); + if (!young.has_value() || !poisson.has_value()) { + continue; + } + + const MaterialId id{next_material_id_++}; + if (!material_ids_.emplace(*current_name, id).second) { + add_error( + "abaqus.semantic.duplicate_material", + "Material '" + *current_name + "' is defined more than once.", + record.source); + continue; + } + builder_.add_material({id, *current_name, *young, *poisson}); + } + } + + void collect_nodes() { + for (const DeckRecord& record : *active_records_) { + if (record.keyword != "NODE") { + continue; + } + for (const auto& row : record.data) { + if (row.size() < 4U) { + add_error( + "abaqus.semantic.invalid_node_data", + "*NODE requires a label and three coordinates.", + record.source); + continue; + } + + const auto label = parse_label(row[0], record, "node"); + const auto x = parse_real(row[1], record, "node coordinate"); + const auto y = parse_real(row[2], record, "node coordinate"); + const auto z = parse_real(row[3], record, "node coordinate"); + if (!label.has_value() || !x.has_value() || !y.has_value() || + !z.has_value()) { + continue; + } + + const NodeId id{next_node_id_++}; + node_ids_.emplace(*label, id); + builder_.add_node({ + id, + EntityOrigin{part_name_, instance_name_, *label}, + Vec3{*x, *y, *z}, + }); + } + } + } + + void collect_raw_sets( + const std::vector& records, + const bool assembly_scope) { + for (const DeckRecord& record : records) { + const bool is_node_set = record.keyword == "NSET"; + const bool is_element_set = record.keyword == "ELSET"; + if (!is_node_set && !is_element_set) { + continue; + } + + const std::string* name = + require_parameter(record, is_node_set ? "NSET" : "ELSET"); + if (name == nullptr) { + continue; + } + if (assembly_scope) { + const std::string* instance = parameter(record, "INSTANCE"); + if (instance == nullptr || *instance != instance_name_) { + add_error( + "abaqus.semantic.wrong_instance", + "Assembly set '" + *name + + "' must reference the active Instance.", + record.source); + continue; + } + } + + std::vector labels; + for (const auto& row : record.data) { + for (const std::string& field : row) { + if (field.empty()) { + continue; + } + const auto label = + parse_label(field, record, "set member"); + if (label.has_value()) { + labels.push_back(*label); + } + } + } + RawSetMap& sets = is_node_set ? raw_node_sets_ : raw_element_sets_; + sets[*name] = std::move(labels); + } + } + + void collect_sections() { + for (const DeckRecord& record : *active_records_) { + if (record.keyword != "BEAM GENERAL SECTION") { + continue; + } + + const std::string* element_set = + require_parameter(record, "ELSET"); + const std::string* material_name = + require_parameter(record, "MATERIAL"); + if (element_set == nullptr || material_name == nullptr) { + continue; + } + const auto material = material_ids_.find(*material_name); + if (material == material_ids_.end()) { + add_error( + "abaqus.semantic.missing_material", + "Beam section references missing Material '" + + *material_name + "'.", + record.source); + continue; + } + if (record.data.size() < 2U || + record.data[0].size() < 5U || + record.data[1].size() < 3U) { + add_error( + "abaqus.semantic.invalid_section_data", + "*BEAM GENERAL SECTION requires general properties and " + "an orientation vector.", + record.source); + continue; + } + + const auto area = parse_real(record.data[0][0], record, "area"); + const auto iy = parse_real(record.data[0][1], record, "Iy"); + const auto iz = parse_real(record.data[0][3], record, "Iz"); + const auto torsion = + parse_real(record.data[0][4], record, "torsion J"); + const auto ox = + parse_real(record.data[1][0], record, "orientation"); + const auto oy = + parse_real(record.data[1][1], record, "orientation"); + const auto oz = + parse_real(record.data[1][2], record, "orientation"); + if (!area.has_value() || !iy.has_value() || !iz.has_value() || + !torsion.has_value() || !ox.has_value() || !oy.has_value() || + !oz.has_value()) { + continue; + } + + const SectionId section_id{next_section_id_++}; + if (!section_assignments_ + .emplace( + *element_set, + SectionAssignment{material->second, section_id}) + .second) { + add_error( + "abaqus.semantic.duplicate_section_assignment", + "Element set '" + *element_set + + "' has more than one section assignment.", + record.source); + continue; + } + builder_.add_section({ + section_id, + *element_set, + *area, + *iy, + *iz, + *torsion, + 5.0 * *area / 6.0, + 5.0 * *area / 6.0, + ShearPropertySource::phase1_default, + Vec3{*ox, *oy, *oz}, + {}, + }); + } + } + + void collect_elements() { + for (const DeckRecord& record : *active_records_) { + if (record.keyword != "ELEMENT") { + continue; + } + + const std::string* type = require_parameter(record, "TYPE"); + const std::string* element_set = + require_parameter(record, "ELSET"); + if (type == nullptr || element_set == nullptr) { + continue; + } + if (*type != "B31") { + add_error( + "abaqus.semantic.unsupported_element", + "Phase 1 supports only B31 elements.", + record.source); + continue; + } + const auto assignment = section_assignments_.find(*element_set); + if (assignment == section_assignments_.end()) { + add_error( + "abaqus.semantic.missing_section", + "Element set '" + *element_set + + "' has no Beam section assignment.", + record.source); + continue; + } + + for (const auto& row : record.data) { + if (row.size() < 3U) { + add_error( + "abaqus.semantic.invalid_element_data", + "B31 element data requires a label and two nodes.", + record.source); + continue; + } + const auto label = parse_label(row[0], record, "element"); + const auto first_label = + parse_label(row[1], record, "node"); + const auto second_label = + parse_label(row[2], record, "node"); + if (!label.has_value() || !first_label.has_value() || + !second_label.has_value()) { + continue; + } + + const auto first = node_ids_.find(*first_label); + const auto second = node_ids_.find(*second_label); + if (first == node_ids_.end() || second == node_ids_.end()) { + add_error( + "abaqus.semantic.missing_node", + "B31 element references a missing node label.", + record.source); + continue; + } + + const ElementId id{next_element_id_++}; + element_ids_.emplace(*label, id); + builder_.add_beam_element({ + id, + EntityOrigin{part_name_, instance_name_, *label}, + {first->second, second->second}, + assignment->second.material, + assignment->second.section, + }); + } + } + } + + template + std::vector resolve_set( + const std::vector& labels, + const Lookup& lookup, + const std::string& name) { + std::vector members; + for (const std::int64_t label : labels) { + const auto found = lookup.find(label); + if (found == lookup.end()) { + diagnostics_.push_back({ + DiagnosticStage::semantic, + Severity::error, + "abaqus.semantic.missing_set_member", + "Set '" + name + "' references a missing entity label " + + std::to_string(label) + ".", + std::nullopt, + }); + continue; + } + members.push_back(found->second); + } + std::ranges::sort(members); + members.erase(std::ranges::unique(members).begin(), members.end()); + return members; + } + + void emit_sets() { + for (const auto& [name, labels] : raw_node_sets_) { + std::vector members = + resolve_set(labels, node_ids_, name); + node_sets_[name] = members; + builder_.add_node_set({name, std::move(members)}); + } + for (const auto& [name, labels] : raw_element_sets_) { + std::vector members = + resolve_set(labels, element_ids_, name); + builder_.add_element_set({name, std::move(members)}); + } + } + + std::vector resolve_node_target( + const std::string& target, + const DeckRecord& record) { + std::int64_t label = 0; + const auto parsed = + std::from_chars(target.data(), target.data() + target.size(), label); + if (parsed.ec == std::errc{} && + parsed.ptr == target.data() + target.size()) { + const auto found = node_ids_.find(label); + if (found != node_ids_.end()) { + return {found->second}; + } + } else { + const auto found = node_sets_.find(target); + if (found != node_sets_.end()) { + return found->second; + } + } + + add_error( + "abaqus.semantic.missing_node_target", + "Node target '" + target + "' does not resolve.", + record.source); + return {}; + } + + void collect_boundary( + const DeckRecord& record, + StepDefinition& step) { + for (const auto& row : record.data) { + if (row.size() < 2U) { + add_error( + "abaqus.semantic.invalid_boundary_data", + "*BOUNDARY requires a target and degree of freedom.", + record.source); + continue; + } + const auto first_dof = parse_dof(row[1], record); + const auto last_dof = row.size() >= 3U && !row[2].empty() + ? parse_dof(row[2], record) + : first_dof; + const auto value = row.size() >= 4U && !row[3].empty() + ? parse_real( + row[3], record, "prescribed value") + : std::optional{0.0}; + if (!first_dof.has_value() || !last_dof.has_value() || + !value.has_value()) { + continue; + } + if (*last_dof < *first_dof) { + add_error( + "abaqus.semantic.invalid_dof_range", + "*BOUNDARY degree-of-freedom range is reversed.", + record.source); + continue; + } + + const std::vector nodes = + resolve_node_target(row[0], record); + for (const NodeId node : nodes) { + for (std::uint8_t dof = *first_dof; dof <= *last_dof; ++dof) { + step.prescribed_dofs.push_back({node, dof, *value}); + } + } + } + } + + void collect_loads( + const DeckRecord& record, + std::map>& loads) { + for (const auto& row : record.data) { + if (row.size() < 3U) { + add_error( + "abaqus.semantic.invalid_cload_data", + "*CLOAD requires a target, degree of freedom, and value.", + record.source); + continue; + } + const auto dof = parse_dof(row[1], record); + const auto value = parse_real(row[2], record, "concentrated load"); + if (!dof.has_value() || !value.has_value()) { + continue; + } + + const std::vector nodes = + resolve_node_target(row[0], record); + for (const NodeId node : nodes) { + loads[node.value()][static_cast(*dof - 1U)] += + *value; + } + } + } + + void collect_step() { + StepDefinition step; + std::map> loads; + bool has_step = false; + + for (const DeckRecord& record : deck_.global_records) { + if (record.keyword == "STEP") { + has_step = true; + const std::string* name = parameter(record, "NAME"); + step.name = name == nullptr ? "Step-1" : *name; + } else if (record.keyword == "BOUNDARY") { + collect_boundary(record, step); + } else if (record.keyword == "CLOAD") { + collect_loads(record, loads); + } + } + if (!has_step) { + const SourceLocation source = + deck_.global_records.empty() + ? SourceLocation{} + : deck_.global_records.front().source; + add_error( + "abaqus.semantic.missing_step", + "Phase 1 input requires one *STEP.", + source); + return; + } + + for (const auto& [node, values] : loads) { + step.nodal_loads.push_back({NodeId{node}, values}); + } + builder_.set_step(std::move(step)); + } + + const ParsedDeck& deck_; + DomainBuilder builder_; + std::vector diagnostics_; + const std::vector* active_records_ = nullptr; + const ParsedAssembly* active_assembly_ = nullptr; + std::string part_name_; + std::string instance_name_; + LabelMap node_ids_; + ElementLabelMap element_ids_; + std::map> material_ids_; + std::map> + section_assignments_; + RawSetMap raw_node_sets_; + RawSetMap raw_element_sets_; + std::map, std::less<>> node_sets_; + std::int64_t next_node_id_ = 0; + std::int64_t next_element_id_ = 0; + std::int64_t next_material_id_ = 0; + std::int64_t next_section_id_ = 0; +}; + +} // namespace + +DomainBuildResult map_deck_to_domain(const ParsedDeck& deck) { + return DeckMapper{deck}.map(); +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e418d76..519a78a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -140,3 +140,33 @@ add_test( COMMAND "$" --gtest_filter=ScopedDeck.* ) + +add_executable(fesa_deck_to_domain_tests + integration/io/minimal_deck_to_domain_test.cpp +) + +target_compile_features(fesa_deck_to_domain_tests PRIVATE cxx_std_20) +target_compile_options(fesa_deck_to_domain_tests PRIVATE /W4 /permissive- /EHsc) +target_compile_definitions( + fesa_deck_to_domain_tests + PRIVATE + FESA_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) + +target_link_libraries(fesa_deck_to_domain_tests + PRIVATE + fesa_core + GTest::gtest_main +) + +add_test( + NAME DeckToDomain + COMMAND "$" + --gtest_filter=DeckToDomain.* +) + +add_test( + NAME ActiveInstance + COMMAND "$" + --gtest_filter=ActiveInstance.* +) diff --git a/tests/integration/io/minimal_deck_to_domain_test.cpp b/tests/integration/io/minimal_deck_to_domain_test.cpp new file mode 100644 index 0000000..82ef9b2 --- /dev/null +++ b/tests/integration/io/minimal_deck_to_domain_test.cpp @@ -0,0 +1,262 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +class TemporaryDeck final { +public: + TemporaryDeck(std::string_view name, std::string_view contents) + : path_{std::filesystem::path{testing::TempDir()} / name} { + std::ofstream output{path_, std::ios::binary}; + output.write( + contents.data(), static_cast(contents.size())); + if (!output) { + throw std::runtime_error{"Failed to write temporary Abaqus deck."}; + } + } + + ~TemporaryDeck() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + TemporaryDeck(const TemporaryDeck&) = delete; + TemporaryDeck& operator=(const TemporaryDeck&) = delete; + + [[nodiscard]] const std::filesystem::path& path() const noexcept { + return path_; + } + +private: + std::filesystem::path path_; +}; + +std::filesystem::path fixture_path(std::string_view name) { + return std::filesystem::path{FESA_TEST_SOURCE_DIR} / "fixtures" / + "abaqus" / name; +} + +fesa::DomainBuildResult parse_and_map(const std::filesystem::path& path) { + const auto parsed = fesa::parse_deck(path); + if (!parsed.deck.has_value()) { + return {std::nullopt, parsed.diagnostics}; + } + return fesa::map_deck_to_domain(*parsed.deck); +} + +bool has_diagnostic( + const fesa::DomainBuildResult& result, + const std::string_view code) { + return std::ranges::any_of( + result.diagnostics, + [code](const fesa::Diagnostic& diagnostic) { + return diagnostic.code == code; + }); +} + +void expect_equivalent_analysis_data( + const fesa::Domain& flat, + const fesa::Domain& hierarchical) { + ASSERT_EQ(flat.nodes().size(), hierarchical.nodes().size()); + ASSERT_EQ(flat.beam_elements().size(), hierarchical.beam_elements().size()); + ASSERT_EQ(flat.materials().size(), hierarchical.materials().size()); + ASSERT_EQ(flat.sections().size(), hierarchical.sections().size()); + + for (std::size_t index = 0; index < flat.nodes().size(); ++index) { + const auto& flat_node = flat.nodes()[index]; + const auto& hierarchical_node = hierarchical.nodes()[index]; + EXPECT_EQ(flat_node.id, hierarchical_node.id); + EXPECT_DOUBLE_EQ(flat_node.position.x, hierarchical_node.position.x); + EXPECT_DOUBLE_EQ(flat_node.position.y, hierarchical_node.position.y); + EXPECT_DOUBLE_EQ(flat_node.position.z, hierarchical_node.position.z); + } + + const auto& flat_element = flat.beam_elements().front(); + const auto& hierarchical_element = hierarchical.beam_elements().front(); + EXPECT_EQ(flat_element.id, hierarchical_element.id); + EXPECT_EQ(flat_element.nodes, hierarchical_element.nodes); + EXPECT_EQ(flat_element.material, hierarchical_element.material); + EXPECT_EQ(flat_element.section, hierarchical_element.section); + + const auto& flat_material = flat.materials().front(); + const auto& hierarchical_material = hierarchical.materials().front(); + EXPECT_EQ(flat_material.id, hierarchical_material.id); + EXPECT_EQ(flat_material.name, hierarchical_material.name); + EXPECT_DOUBLE_EQ(flat_material.young, hierarchical_material.young); + EXPECT_DOUBLE_EQ(flat_material.poisson, hierarchical_material.poisson); + + const auto& flat_section = flat.sections().front(); + const auto& hierarchical_section = hierarchical.sections().front(); + EXPECT_EQ(flat_section.id, hierarchical_section.id); + EXPECT_EQ(flat_section.name, hierarchical_section.name); + EXPECT_DOUBLE_EQ(flat_section.area, hierarchical_section.area); + EXPECT_DOUBLE_EQ(flat_section.iy, hierarchical_section.iy); + EXPECT_DOUBLE_EQ(flat_section.iz, hierarchical_section.iz); + EXPECT_DOUBLE_EQ(flat_section.torsion_j, hierarchical_section.torsion_j); + EXPECT_DOUBLE_EQ( + flat_section.shear_area_y, hierarchical_section.shear_area_y); + EXPECT_DOUBLE_EQ( + flat_section.shear_area_z, hierarchical_section.shear_area_z); + + ASSERT_EQ( + flat.step().prescribed_dofs.size(), + hierarchical.step().prescribed_dofs.size()); + for (std::size_t index = 0; + index < flat.step().prescribed_dofs.size(); + ++index) { + const auto& flat_value = flat.step().prescribed_dofs[index]; + const auto& hierarchical_value = + hierarchical.step().prescribed_dofs[index]; + EXPECT_EQ(flat_value.node, hierarchical_value.node); + EXPECT_EQ(flat_value.dof, hierarchical_value.dof); + EXPECT_DOUBLE_EQ(flat_value.value, hierarchical_value.value); + } + + ASSERT_EQ(flat.step().nodal_loads.size(), 1U); + ASSERT_EQ(hierarchical.step().nodal_loads.size(), 1U); + EXPECT_EQ( + flat.step().nodal_loads[0].node, + hierarchical.step().nodal_loads[0].node); + EXPECT_EQ( + flat.step().nodal_loads[0].values, + hierarchical.step().nodal_loads[0].values); +} + +TEST(DeckToDomain, NormalizesFlatAndSingleInstanceDecksEquivalently) { + const auto flat = + parse_and_map(fixture_path("minimal_cantilever.inp")); + const auto hierarchical = + parse_and_map(fixture_path("minimal_part_instance_cantilever.inp")); + + ASSERT_TRUE(flat.domain.has_value()); + ASSERT_TRUE(hierarchical.domain.has_value()); + EXPECT_TRUE(flat.diagnostics.empty()); + EXPECT_TRUE(hierarchical.diagnostics.empty()); + expect_equivalent_analysis_data(*flat.domain, *hierarchical.domain); + + for (const auto& node : flat.domain->nodes()) { + EXPECT_TRUE(node.origin.part_name.empty()); + EXPECT_TRUE(node.origin.instance_name.empty()); + } + for (const auto& node : hierarchical.domain->nodes()) { + EXPECT_EQ(node.origin.part_name, "BeamPart"); + EXPECT_EQ(node.origin.instance_name, "Beam-1"); + } + EXPECT_EQ( + hierarchical.domain->beam_elements()[0].origin, + (fesa::EntityOrigin{"BeamPart", "Beam-1", 1})); + + const auto& section = hierarchical.domain->sections().front(); + EXPECT_DOUBLE_EQ(section.shear_area_y, 5.0 * section.area / 6.0); + EXPECT_DOUBLE_EQ(section.shear_area_z, 5.0 * section.area / 6.0); + EXPECT_EQ( + section.shear_source, + fesa::ShearPropertySource::phase1_default); +} + +TEST(ActiveInstance, ExcludesPartsNotReferencedByTheInstance) { + const TemporaryDeck input{ + "fesa-active-instance.inp", + "*PART, NAME=Unused\n" + "*NODE\n" + "99, 9.0, 0.0, 0.0\n" + "*END PART\n" + "*PART, NAME=BeamPart\n" + "*NODE\n" + "1, 0.0, 0.0, 0.0\n" + "2, 1.0, 0.0, 0.0\n" + "*ELEMENT, TYPE=B31, ELSET=Beam\n" + "1, 1, 2\n" + "*ELSET, ELSET=Beam\n" + "1\n" + "*BEAM GENERAL SECTION, SECTION=GENERAL, ELSET=Beam, MATERIAL=Steel\n" + "1.0, 1.0, 0.0, 1.0, 1.0\n" + "0.0, 1.0, 0.0\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=BeamPart\n" + "*END INSTANCE\n" + "*END ASSEMBLY\n" + "*MATERIAL, NAME=Steel\n" + "*ELASTIC\n" + "210000.0, 0.3\n" + "*STEP, NAME=Load\n" + "*STATIC\n" + "*END STEP\n"}; + + const auto result = parse_and_map(input.path()); + + ASSERT_TRUE(result.domain.has_value()); + ASSERT_EQ(result.domain->nodes().size(), 2U); + EXPECT_EQ(result.domain->nodes()[0].origin.part_name, "BeamPart"); + EXPECT_EQ(result.domain->nodes()[1].origin.part_name, "BeamPart"); +} + +TEST(ActiveInstance, RejectsInstanceTransformWithSourceDiagnostic) { + const TemporaryDeck input{ + "fesa-instance-transform.inp", + "*PART, NAME=BeamPart\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=BeamPart\n" + "1.0, 2.0, 3.0\n" + "*END INSTANCE\n" + "*END ASSEMBLY\n"}; + + const auto result = parse_and_map(input.path()); + + EXPECT_FALSE(result.domain.has_value()); + ASSERT_TRUE(has_diagnostic(result, "abaqus.semantic.instance_transform")); + ASSERT_FALSE(result.diagnostics.empty()); + ASSERT_TRUE(result.diagnostics.front().source.has_value()); + EXPECT_EQ(result.diagnostics.front().source->line, 4U); +} + +TEST(ActiveInstance, RejectsMissingPartReferenceWithSourceDiagnostic) { + const TemporaryDeck input{ + "fesa-missing-part.inp", + "*PART, NAME=OtherPart\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=MissingPart\n" + "*END INSTANCE\n" + "*END ASSEMBLY\n"}; + + const auto result = parse_and_map(input.path()); + + EXPECT_FALSE(result.domain.has_value()); + ASSERT_TRUE(has_diagnostic(result, "abaqus.semantic.missing_part")); + ASSERT_FALSE(result.diagnostics.empty()); + ASSERT_TRUE(result.diagnostics.front().source.has_value()); + EXPECT_EQ(result.diagnostics.front().source->line, 4U); +} + +TEST(ActiveInstance, RejectsMultipleInstances) { + const TemporaryDeck input{ + "fesa-multiple-instances.inp", + "*PART, NAME=BeamPart\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=BeamPart\n" + "*END INSTANCE\n" + "*INSTANCE, NAME=Beam-2, PART=BeamPart\n" + "*END INSTANCE\n" + "*END ASSEMBLY\n"}; + + const auto result = parse_and_map(input.path()); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "abaqus.semantic.instance_count")); +} + +} // namespace