diff --git a/CMakeLists.txt b/CMakeLists.txt index aa251cc..e87b919 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,7 @@ add_library(fesa_core STATIC src/fesa/fem/line2_shape.cpp src/fesa/io/abaqus/parser.cpp src/fesa/io/abaqus/semantic_mapper.cpp + src/fesa/io/abaqus/set_resolver.cpp src/fesa/io/hdf5/writer.cpp src/fesa/model/domain.cpp src/fesa/model/domain_builder.cpp diff --git a/include/fesa/io/abaqus/deck_record.hpp b/include/fesa/io/abaqus/deck_record.hpp index d685f3d..fa2bcb9 100644 --- a/include/fesa/io/abaqus/deck_record.hpp +++ b/include/fesa/io/abaqus/deck_record.hpp @@ -15,6 +15,7 @@ struct DeckRecord final { std::map> parameters; std::vector> data; SourceLocation source; + std::vector data_sources; }; struct ParsedPart final { diff --git a/include/fesa/io/abaqus/set_resolver.hpp b/include/fesa/io/abaqus/set_resolver.hpp new file mode 100644 index 0000000..c399312 --- /dev/null +++ b/include/fesa/io/abaqus/set_resolver.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace fesa { + +enum class ResolvedSetScope { global, part, assembly }; + +enum class ResolvedSetKind { node, element }; + +struct ResolvedSet final { + std::string scope_name; + std::string set_name; + std::vector sorted_unique_labels; + ResolvedSetScope scope = ResolvedSetScope::global; + ResolvedSetKind kind = ResolvedSetKind::node; +}; + +struct SetResolutionResult final { + std::vector sets; + std::vector diagnostics; +}; + +[[nodiscard]] SetResolutionResult resolve_sets(const ParsedDeck& deck); + +} // namespace fesa diff --git a/src/fesa/io/abaqus/parser.cpp b/src/fesa/io/abaqus/parser.cpp index 55b2c53..e182e30 100644 --- a/src/fesa/io/abaqus/parser.cpp +++ b/src/fesa/io/abaqus/parser.cpp @@ -216,6 +216,11 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { current_instance->transform_data.push_back(fields); } else if (current_record != nullptr) { current_record->data.push_back(fields); + current_record->data_sources.push_back(SourceLocation{ + path, + line_number, + first_nonspace + 1U, + }); } else { return syntax_failure( "abaqus.syntax.data_without_keyword", diff --git a/src/fesa/io/abaqus/set_resolver.cpp b/src/fesa/io/abaqus/set_resolver.cpp new file mode 100644 index 0000000..f2d5ba0 --- /dev/null +++ b/src/fesa/io/abaqus/set_resolver.cpp @@ -0,0 +1,411 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { +namespace { + +struct SetNamespace final { + ResolvedSetScope scope; + std::string scope_name; + ResolvedSetKind kind; + + auto operator<=>(const SetNamespace&) const = default; +}; + +struct SetKey final { + SetNamespace name_space; + std::string set_name; + + auto operator<=>(const SetKey&) const = default; +}; + +struct RawMember final { + std::string text; + SourceLocation source; +}; + +struct RawSet final { + std::vector members; +}; + +enum class VisitState { unvisited, visiting, resolved, failed }; + +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 has_parameter( + const DeckRecord& record, + const std::string_view name) { + return record.parameters.contains(name); +} + +SourceLocation data_source( + const DeckRecord& record, + const std::size_t row) { + return row < record.data_sources.size() + ? record.data_sources[row] + : record.source; +} + +bool parse_positive_label( + const std::string_view text, + std::int64_t& value) { + const auto parsed = + std::from_chars(text.data(), text.data() + text.size(), value); + return parsed.ec == std::errc{} && + parsed.ptr == text.data() + text.size() && value > 0; +} + +class SetResolver final { +public: + explicit SetResolver(const ParsedDeck& deck) : deck_{deck} {} + + [[nodiscard]] SetResolutionResult resolve() { + collect_scope( + deck_.global_records, + {ResolvedSetScope::global, "global", ResolvedSetKind::node}); + + for (const ParsedPart& part : deck_.parts) { + collect_scope( + part.records, + {ResolvedSetScope::part, + part.name, + ResolvedSetKind::node}); + } + + collect_assembly(); + + std::vector sets; + sets.reserve(raw_sets_.size()); + for (const auto& [key, raw_set] : raw_sets_) { + static_cast(raw_set); + if (!resolve_set(key)) { + continue; + } + sets.push_back({ + key.name_space.scope_name, + key.set_name, + resolved_sets_.at(key), + key.name_space.scope, + key.name_space.kind, + }); + } + + if (!diagnostics_.empty()) { + sets.clear(); + } + return {std::move(sets), std::move(diagnostics_)}; + } + +private: + 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, + }); + } + + void collect_scope( + const std::vector& records, + SetNamespace name_space) { + collect_entities(records, name_space); + collect_set_records(records, std::move(name_space), nullptr); + } + + void collect_entities( + const std::vector& records, + const SetNamespace& base_namespace) { + for (const DeckRecord& record : records) { + ResolvedSetKind kind; + if (record.keyword == "NODE") { + kind = ResolvedSetKind::node; + } else if (record.keyword == "ELEMENT") { + kind = ResolvedSetKind::element; + } else { + continue; + } + + SetNamespace entity_namespace = base_namespace; + entity_namespace.kind = kind; + std::set& labels = entities_[entity_namespace]; + for (std::size_t row = 0; row < record.data.size(); ++row) { + if (record.data[row].empty()) { + continue; + } + std::int64_t label = 0; + if (!parse_positive_label(record.data[row][0], label)) { + continue; + } + labels.insert(label); + + if (kind != ResolvedSetKind::element) { + continue; + } + const std::string* set_name = parameter(record, "ELSET"); + if (set_name == nullptr || set_name->empty()) { + continue; + } + raw_sets_[{entity_namespace, *set_name}].members.push_back({ + std::to_string(label), + data_source(record, row), + }); + } + } + } + + void collect_set_records( + const std::vector& records, + const SetNamespace& base_namespace, + const std::string* active_instance) { + for (const DeckRecord& record : records) { + SetNamespace set_namespace = base_namespace; + std::string_view name_parameter; + if (record.keyword == "NSET") { + set_namespace.kind = ResolvedSetKind::node; + name_parameter = "NSET"; + } else if (record.keyword == "ELSET") { + set_namespace.kind = ResolvedSetKind::element; + name_parameter = "ELSET"; + } else { + continue; + } + + const std::string* set_name = parameter(record, name_parameter); + if (set_name == nullptr || set_name->empty()) { + add_error( + "abaqus.semantic.missing_parameter", + "*" + record.keyword + " requires parameter " + + std::string{name_parameter} + ".", + record.source); + continue; + } + + if (set_namespace.scope == ResolvedSetScope::assembly) { + const std::string* instance = parameter(record, "INSTANCE"); + if (active_instance == nullptr || instance == nullptr || + *instance != *active_instance) { + add_error( + "abaqus.semantic.wrong_instance", + "Assembly set '" + *set_name + + "' must reference the active Instance.", + record.source); + continue; + } + } + + RawSet& raw_set = raw_sets_[{set_namespace, *set_name}]; + if (has_parameter(record, "GENERATE")) { + collect_generate(record, raw_set); + } else { + collect_explicit(record, raw_set); + } + } + } + + void collect_explicit( + const DeckRecord& record, + RawSet& raw_set) { + for (std::size_t row = 0; row < record.data.size(); ++row) { + for (const std::string& field : record.data[row]) { + if (field.empty()) { + continue; + } + raw_set.members.push_back({field, data_source(record, row)}); + } + } + } + + void collect_generate( + const DeckRecord& record, + RawSet& raw_set) { + const SourceLocation source = + record.data.empty() ? record.source : data_source(record, 0U); + if (record.data.size() != 1U || record.data[0].size() != 3U || + std::ranges::any_of( + record.data[0], + [](const std::string& field) { return field.empty(); })) { + add_error( + "abaqus.semantic.invalid_generate", + "*" + record.keyword + + ", GENERATE requires exactly start, end, increment.", + source); + return; + } + + std::int64_t start = 0; + std::int64_t end = 0; + std::int64_t increment = 0; + if (!parse_positive_label(record.data[0][0], start) || + !parse_positive_label(record.data[0][1], end) || + !parse_positive_label(record.data[0][2], increment) || + start > end || (end - start) % increment != 0) { + add_error( + "abaqus.semantic.invalid_generate", + "Invalid *" + record.keyword + " generate range.", + source); + return; + } + + for (std::int64_t label = start;; label += increment) { + raw_set.members.push_back({std::to_string(label), source}); + if (label == end) { + break; + } + } + } + + void collect_assembly() { + if (!deck_.assembly.has_value()) { + return; + } + + const ParsedAssembly& assembly = *deck_.assembly; + const ParsedInstance* active_instance = + assembly.instances.size() == 1U + ? &assembly.instances.front() + : nullptr; + SetNamespace assembly_namespace{ + ResolvedSetScope::assembly, + assembly.name, + ResolvedSetKind::node, + }; + + if (active_instance != nullptr) { + const auto part = std::ranges::find( + deck_.parts, active_instance->part_name, &ParsedPart::name); + if (part != deck_.parts.end()) { + for (const ResolvedSetKind kind : { + ResolvedSetKind::node, + ResolvedSetKind::element}) { + const SetNamespace part_namespace{ + ResolvedSetScope::part, + part->name, + kind, + }; + SetNamespace lifted_namespace = assembly_namespace; + lifted_namespace.kind = kind; + const auto labels = entities_.find(part_namespace); + if (labels != entities_.end()) { + entities_[lifted_namespace] = labels->second; + } + } + } + } + + const std::string* instance_name = + active_instance == nullptr ? nullptr : &active_instance->name; + collect_set_records( + assembly.records, assembly_namespace, instance_name); + } + + bool resolve_set(const SetKey& key) { + VisitState& state = states_[key]; + if (state == VisitState::resolved) { + return true; + } + if (state == VisitState::failed) { + return false; + } + + state = VisitState::visiting; + bool succeeded = true; + std::vector labels; + for (const RawMember& member : raw_sets_.at(key).members) { + std::int64_t label = 0; + if (parse_positive_label(member.text, label)) { + const auto entity_namespace = entities_.find(key.name_space); + if (entity_namespace == entities_.end() || + !entity_namespace->second.contains(label)) { + add_error( + "abaqus.semantic.missing_set_member", + "Set '" + key.set_name + + "' references missing entity label " + + std::to_string(label) + ".", + member.source); + succeeded = false; + continue; + } + labels.push_back(label); + continue; + } + + const SetKey nested_key{key.name_space, member.text}; + const auto nested = raw_sets_.find(nested_key); + if (nested == raw_sets_.end()) { + add_error( + "abaqus.semantic.missing_set_member", + "Set '" + key.set_name + "' references missing set '" + + member.text + "'.", + member.source); + succeeded = false; + continue; + } + + if (states_[nested_key] == VisitState::visiting) { + add_error( + "abaqus.semantic.set_cycle", + "Set '" + key.set_name + + "' closes a nested set reference cycle through '" + + member.text + "'.", + member.source); + succeeded = false; + continue; + } + if (!resolve_set(nested_key)) { + succeeded = false; + continue; + } + const std::vector& nested_labels = + resolved_sets_.at(nested_key); + labels.insert( + labels.end(), nested_labels.begin(), nested_labels.end()); + } + + if (!succeeded) { + state = VisitState::failed; + return false; + } + + std::ranges::sort(labels); + labels.erase(std::ranges::unique(labels).begin(), labels.end()); + resolved_sets_[key] = std::move(labels); + state = VisitState::resolved; + return true; + } + + const ParsedDeck& deck_; + std::map> entities_; + std::map raw_sets_; + std::map states_; + std::map> resolved_sets_; + std::vector diagnostics_; +}; + +} // namespace + +SetResolutionResult resolve_sets(const ParsedDeck& deck) { + return SetResolver{deck}.resolve(); +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f52859e..c51a102 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -120,6 +120,7 @@ add_test( add_executable(fesa_abaqus_parser_tests unit/io/abaqus/input_contract_test.cpp unit/io/abaqus/parser_test.cpp + unit/io/abaqus/set_resolution_test.cpp ) target_compile_features(fesa_abaqus_parser_tests PRIVATE cxx_std_20) @@ -156,6 +157,24 @@ add_test( # Steps 1-4 make this normative matrix pass, then remove WILL_FAIL. set_tests_properties(AbaqusInputContract PROPERTIES WILL_FAIL TRUE) +add_test( + NAME SetResolution + COMMAND "$" + --gtest_filter=SetResolution.* +) + +add_test( + NAME PartSet + COMMAND "$" + --gtest_filter=PartSet.* +) + +add_test( + NAME AssemblySet + COMMAND "$" + --gtest_filter=AssemblySet.* +) + add_executable(fesa_deck_to_domain_tests integration/io/minimal_deck_to_domain_test.cpp ) diff --git a/tests/unit/io/abaqus/set_resolution_test.cpp b/tests/unit/io/abaqus/set_resolution_test.cpp new file mode 100644 index 0000000..9102e96 --- /dev/null +++ b/tests/unit/io/abaqus/set_resolution_test.cpp @@ -0,0 +1,328 @@ +#include +#include + +#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_; +}; + +fesa::SetResolutionResult parse_and_resolve( + const TemporaryDeck& input) { + const fesa::ParseDeckResult parsed = fesa::parse_deck(input.path()); + if (!parsed.deck.has_value()) { + throw std::runtime_error{"Test deck did not parse."}; + } + return fesa::resolve_sets(*parsed.deck); +} + +const fesa::ResolvedSet& find_set( + const fesa::SetResolutionResult& result, + const fesa::ResolvedSetScope scope, + const std::string_view scope_name, + const fesa::ResolvedSetKind kind, + const std::string_view set_name) { + const auto found = std::ranges::find_if( + result.sets, + [=](const fesa::ResolvedSet& set) { + return set.scope == scope && set.scope_name == scope_name && + set.kind == kind && set.set_name == set_name; + }); + if (found == result.sets.end()) { + throw std::runtime_error{"Expected resolved set was not found."}; + } + return *found; +} + +const fesa::Diagnostic& find_diagnostic( + const fesa::SetResolutionResult& result, + const std::string_view code, + const std::size_t line) { + const auto found = std::ranges::find_if( + result.diagnostics, + [=](const fesa::Diagnostic& diagnostic) { + return diagnostic.code == code && diagnostic.source.has_value() && + diagnostic.source->line == line; + }); + if (found == result.diagnostics.end()) { + throw std::runtime_error{"Expected set diagnostic was not found."}; + } + return *found; +} + +TEST( + SetResolution, + CanonicalizesExplicitGenerateNestedForwardDuplicateAndEmptySets) { + const TemporaryDeck input{ + "fesa-set-resolution-valid.inp", + "*PART, NAME=BeamPart\n" + "*NODE\n" + "1, 0, 0, 0\n" + "2, 1, 0, 0\n" + "3, 2, 0, 0\n" + "*ELEMENT, TYPE=B31, ELSET=ImplicitElements\n" + "10, 1, 2\n" + "20, 2, 3\n" + "*NSET, NSET=AllNodes\n" + "GeneratedNodes, 3, 1, 3\n" + "*NSET, NSET=GeneratedNodes, GENERATE\n" + "1, 3, 1\n" + "*NSET, NSET=EmptyNodes\n" + "*ELSET, ELSET=AllElements\n" + "GeneratedElements, ImplicitElements, 20\n" + "*ELSET, ELSET=GeneratedElements, GENERATE\n" + "10, 20, 10\n" + "*END PART\n"}; + + const fesa::SetResolutionResult result = parse_and_resolve(input); + + ASSERT_TRUE(result.diagnostics.empty()); + EXPECT_EQ( + find_set( + result, + fesa::ResolvedSetScope::part, + "BeamPart", + fesa::ResolvedSetKind::node, + "AllNodes") + .sorted_unique_labels, + (std::vector{1, 2, 3})); + EXPECT_TRUE( + find_set( + result, + fesa::ResolvedSetScope::part, + "BeamPart", + fesa::ResolvedSetKind::node, + "EmptyNodes") + .sorted_unique_labels.empty()); + EXPECT_EQ( + find_set( + result, + fesa::ResolvedSetScope::part, + "BeamPart", + fesa::ResolvedSetKind::element, + "AllElements") + .sorted_unique_labels, + (std::vector{10, 20})); +} + +TEST(PartSet, KeepsNodeAndElementSetNamespacesSeparate) { + const TemporaryDeck input{ + "fesa-part-set-kind-collision.inp", + "*PART, NAME=BeamPart\n" + "*NODE\n" + "1, 0, 0, 0\n" + "2, 1, 0, 0\n" + "*ELEMENT, TYPE=B31\n" + "1, 1, 2\n" + "*NSET, NSET=Shared\n" + "1\n" + "*ELSET, ELSET=Shared\n" + "1\n" + "*END PART\n"}; + + const fesa::SetResolutionResult result = parse_and_resolve(input); + + ASSERT_TRUE(result.diagnostics.empty()); + EXPECT_EQ( + find_set( + result, + fesa::ResolvedSetScope::part, + "BeamPart", + fesa::ResolvedSetKind::node, + "Shared") + .sorted_unique_labels, + (std::vector{1})); + EXPECT_EQ( + find_set( + result, + fesa::ResolvedSetScope::part, + "BeamPart", + fesa::ResolvedSetKind::element, + "Shared") + .sorted_unique_labels, + (std::vector{1})); +} + +TEST(AssemblySet, KeepsPartScopeSeparateAndLiftsActivePartLabels) { + const TemporaryDeck input{ + "fesa-assembly-set-scope-collision.inp", + "*PART, NAME=BeamPart\n" + "*NODE\n" + "1, 0, 0, 0\n" + "2, 1, 0, 0\n" + "*NSET, NSET=Shared\n" + "1\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=BeamPart\n" + "*END INSTANCE\n" + "*NSET, NSET=Shared, INSTANCE=Beam-1\n" + "2, 2\n" + "*END ASSEMBLY\n"}; + + const fesa::SetResolutionResult result = parse_and_resolve(input); + + ASSERT_TRUE(result.diagnostics.empty()); + ASSERT_EQ(result.sets.size(), 2U); + EXPECT_EQ( + find_set( + result, + fesa::ResolvedSetScope::part, + "BeamPart", + fesa::ResolvedSetKind::node, + "Shared") + .sorted_unique_labels, + (std::vector{1})); + EXPECT_EQ( + find_set( + result, + fesa::ResolvedSetScope::assembly, + "RootAssembly", + fesa::ResolvedSetKind::node, + "Shared") + .sorted_unique_labels, + (std::vector{2})); + EXPECT_TRUE(std::ranges::is_sorted( + result.sets, + [](const fesa::ResolvedSet& left, const fesa::ResolvedSet& right) { + return std::tuple{ + left.scope, + left.scope_name, + left.kind, + left.set_name} < + std::tuple{ + right.scope, + right.scope_name, + right.kind, + right.set_name}; + })); +} + +TEST(SetResolution, ReportsCycleAtTheClosingReferenceSource) { + const TemporaryDeck input{ + "fesa-set-resolution-cycle.inp", + "*NSET, NSET=First\n" + "Second\n" + "*NSET, NSET=Second\n" + "First\n"}; + + const fesa::SetResolutionResult result = parse_and_resolve(input); + + EXPECT_TRUE(result.sets.empty()); + const fesa::Diagnostic& diagnostic = + find_diagnostic(result, "abaqus.semantic.set_cycle", 4U); + EXPECT_EQ(diagnostic.stage, fesa::DiagnosticStage::semantic); + EXPECT_EQ(diagnostic.source->file, input.path()); +} + +TEST(SetResolution, ReportsUnknownSetAndEntityAtTheirMemberRows) { + const TemporaryDeck input{ + "fesa-set-resolution-missing.inp", + "*NODE\n" + "1, 0, 0, 0\n" + "*NSET, NSET=MissingEntity\n" + "2\n" + "*NSET, NSET=MissingSet\n" + "Unknown\n"}; + + const fesa::SetResolutionResult result = parse_and_resolve(input); + + EXPECT_TRUE(result.sets.empty()); + EXPECT_EQ(result.diagnostics.size(), 2U); + EXPECT_EQ( + find_diagnostic( + result, "abaqus.semantic.missing_set_member", 4U) + .stage, + fesa::DiagnosticStage::semantic); + EXPECT_EQ( + find_diagnostic( + result, "abaqus.semantic.missing_set_member", 6U) + .stage, + fesa::DiagnosticStage::semantic); +} + +TEST(SetResolution, ReportsInvalidGenerateRangesInInputOrder) { + const TemporaryDeck input{ + "fesa-set-resolution-generate-invalid.inp", + "*NSET, NSET=Reversed, GENERATE\n" + "3, 1, 1\n" + "*NSET, NSET=NotDivisible, GENERATE\n" + "1, 4, 2\n" + "*ELSET, ELSET=WrongFieldCount, GENERATE\n" + "1, 2\n"}; + + const fesa::SetResolutionResult result = parse_and_resolve(input); + + EXPECT_TRUE(result.sets.empty()); + ASSERT_EQ(result.diagnostics.size(), 3U); + for (std::size_t index = 0; index < result.diagnostics.size(); ++index) { + EXPECT_EQ( + result.diagnostics[index].code, + "abaqus.semantic.invalid_generate"); + ASSERT_TRUE(result.diagnostics[index].source.has_value()); + } + EXPECT_EQ(result.diagnostics[0].source->line, 2U); + EXPECT_EQ(result.diagnostics[1].source->line, 4U); + EXPECT_EQ(result.diagnostics[2].source->line, 6U); +} + +TEST(AssemblySet, RejectsAnInstanceOtherThanTheSingleActiveInstance) { + const TemporaryDeck input{ + "fesa-assembly-set-wrong-instance.inp", + "*PART, NAME=BeamPart\n" + "*NODE\n" + "1, 0, 0, 0\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=BeamPart\n" + "*END INSTANCE\n" + "*NSET, NSET=Fixed, INSTANCE=Other\n" + "1\n" + "*END ASSEMBLY\n"}; + + const fesa::SetResolutionResult result = parse_and_resolve(input); + + EXPECT_TRUE(result.sets.empty()); + EXPECT_EQ( + find_diagnostic(result, "abaqus.semantic.wrong_instance", 8U) + .stage, + fesa::DiagnosticStage::semantic); +} + +} // namespace