feat(abaqus-subset-completion): step 2 — single-instance-semantic-validation

This commit is contained in:
KOKO\Mimi
2026-08-01 02:44:01 +09:00
parent 3eeab2fbe4
commit e6bc708be3
9 changed files with 417 additions and 82 deletions
+1
View File
@@ -36,6 +36,7 @@ add_library(fesa_core STATIC
src/fesa/fem/dof_manager.cpp src/fesa/fem/dof_manager.cpp
src/fesa/fem/gauss_rule.cpp src/fesa/fem/gauss_rule.cpp
src/fesa/fem/line2_shape.cpp src/fesa/fem/line2_shape.cpp
src/fesa/io/abaqus/active_input.cpp
src/fesa/io/abaqus/parser.cpp src/fesa/io/abaqus/parser.cpp
src/fesa/io/abaqus/semantic_mapper.cpp src/fesa/io/abaqus/semantic_mapper.cpp
src/fesa/io/abaqus/set_resolver.cpp src/fesa/io/abaqus/set_resolver.cpp
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <optional>
#include <span>
#include <string>
#include <vector>
#include <fesa/core/diagnostic.hpp>
#include <fesa/io/abaqus/deck_record.hpp>
namespace fesa {
struct ActiveInputView final {
bool flat = true;
std::string part_name;
std::string instance_name;
std::span<const DeckRecord> part_records;
std::span<const DeckRecord> assembly_records;
};
struct ActiveInputResult final {
std::optional<ActiveInputView> input;
std::vector<Diagnostic> diagnostics;
};
[[nodiscard]] ActiveInputResult select_active_input(const ParsedDeck& deck);
} // namespace fesa
+1
View File
@@ -28,6 +28,7 @@ struct ParsedInstance final {
std::string name; std::string name;
std::string part_name; std::string part_name;
std::vector<std::vector<std::string>> transform_data; std::vector<std::vector<std::string>> transform_data;
std::vector<SourceLocation> transform_sources;
SourceLocation source; SourceLocation source;
}; };
+135
View File
@@ -0,0 +1,135 @@
#include <fesa/io/abaqus/active_input.hpp>
#include <algorithm>
#include <string>
#include <string_view>
#include <utility>
namespace fesa {
namespace {
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";
}
ActiveInputResult failure(
std::string code,
std::string message,
const SourceLocation& source) {
return {
std::nullopt,
{{
DiagnosticStage::semantic,
Severity::error,
std::move(code),
std::move(message),
source,
}},
};
}
} // namespace
ActiveInputResult select_active_input(const ParsedDeck& deck) {
const bool hierarchical =
!deck.parts.empty() || deck.assembly.has_value();
if (!hierarchical) {
return {
ActiveInputView{
true,
{},
{},
deck.global_records,
{},
},
{},
};
}
const auto flat_mesh =
std::ranges::find_if(deck.global_records, is_mesh_record);
if (flat_mesh != deck.global_records.end()) {
return failure(
"abaqus.semantic.mixed_mesh_organization",
"Flat mesh records cannot be mixed with Part/Assembly input.",
flat_mesh->source);
}
if (!deck.assembly.has_value()) {
const SourceLocation source =
deck.parts.empty() ? SourceLocation{} : deck.parts.front().source;
return failure(
"abaqus.semantic.assembly_count",
"Hierarchical Phase 1 input requires exactly one Assembly.",
source);
}
const ParsedAssembly& assembly = *deck.assembly;
if (assembly.instances.size() != 1U) {
const SourceLocation& source = assembly.instances.size() > 1U
? assembly.instances[1].source
: assembly.source;
return failure(
"abaqus.semantic.instance_count",
"Phase 1 requires exactly one Instance.",
source);
}
const ParsedInstance& instance = assembly.instances.front();
if (!instance.transform_data.empty()) {
const SourceLocation& source = instance.transform_sources.empty()
? instance.source
: instance.transform_sources.front();
return failure(
"abaqus.semantic.instance_transform",
"Instance translation and rotation data are unsupported.",
source);
}
const auto part =
std::ranges::find(deck.parts, instance.part_name, &ParsedPart::name);
if (part == deck.parts.end()) {
return failure(
"abaqus.semantic.missing_part",
"Instance '" + instance.name + "' references missing Part '" +
instance.part_name + "'.",
instance.source);
}
for (const DeckRecord& record : assembly.records) {
if (record.keyword != "NSET" && record.keyword != "ELSET") {
continue;
}
const std::string* record_instance = parameter(record, "INSTANCE");
if (record_instance == nullptr || *record_instance != instance.name) {
const std::string* name = parameter(
record, record.keyword == "NSET" ? "NSET" : "ELSET");
return failure(
"abaqus.semantic.wrong_instance",
"Assembly set '" +
(name == nullptr ? std::string{} : *name) +
"' must reference the active Instance.",
record.source);
}
}
return {
ActiveInputView{
false,
part->name,
instance.name,
part->records,
assembly.records,
},
{},
};
}
} // namespace fesa
+6 -1
View File
@@ -214,6 +214,11 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) {
const std::vector<std::string> fields = split_fields(content); const std::vector<std::string> fields = split_fields(content);
if (scope == Scope::instance) { if (scope == Scope::instance) {
current_instance->transform_data.push_back(fields); current_instance->transform_data.push_back(fields);
current_instance->transform_sources.push_back(SourceLocation{
path,
line_number,
first_nonspace + 1U,
});
} else if (current_record != nullptr) { } else if (current_record != nullptr) {
current_record->data.push_back(fields); current_record->data.push_back(fields);
current_record->data_sources.push_back(SourceLocation{ current_record->data_sources.push_back(SourceLocation{
@@ -327,7 +332,7 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) {
return missing_parameter(keyword, "PART"); return missing_parameter(keyword, "PART");
} }
current_instance = current_instance =
ParsedInstance{*name, *part_name, {}, source}; ParsedInstance{*name, *part_name, {}, {}, source};
scope = Scope::instance; scope = Scope::instance;
continue; continue;
} }
+18 -79
View File
@@ -1,5 +1,7 @@
#include <fesa/io/abaqus/semantic_mapper.hpp> #include <fesa/io/abaqus/semantic_mapper.hpp>
#include <fesa/io/abaqus/active_input.hpp>
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <charconv> #include <charconv>
@@ -26,24 +28,26 @@ const std::string* parameter(
return found == record.parameters.end() ? nullptr : &found->second; 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 { class DeckMapper final {
public: public:
explicit DeckMapper(const ParsedDeck& deck) : deck_{deck} {} explicit DeckMapper(const ParsedDeck& deck) : deck_{deck} {}
[[nodiscard]] DomainBuildResult map() { [[nodiscard]] DomainBuildResult map() {
if (!select_active_scope()) { ActiveInputResult selected = select_active_input(deck_);
if (!selected.input.has_value()) {
diagnostics_ = std::move(selected.diagnostics);
return failure(); return failure();
} }
active_records_ = selected.input->part_records;
assembly_records_ = selected.input->assembly_records;
part_name_ = std::move(selected.input->part_name);
instance_name_ = std::move(selected.input->instance_name);
collect_materials(); collect_materials();
collect_nodes(); collect_nodes();
collect_raw_sets(*active_records_, false); collect_raw_sets(active_records_, false);
if (active_assembly_ != nullptr) { if (!assembly_records_.empty()) {
collect_raw_sets(active_assembly_->records, true); collect_raw_sets(assembly_records_, true);
} }
collect_sections(); collect_sections();
collect_elements(); collect_elements();
@@ -153,71 +157,6 @@ private:
return value; 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) {
const SourceLocation& source =
active_assembly_->instances.size() > 1U
? active_assembly_->instances[1].source
: active_assembly_->source;
add_error(
"abaqus.semantic.instance_count",
"Phase 1 requires exactly one Instance.",
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() { void collect_materials() {
const std::string* current_name = nullptr; const std::string* current_name = nullptr;
for (const DeckRecord& record : deck_.global_records) { for (const DeckRecord& record : deck_.global_records) {
@@ -264,7 +203,7 @@ private:
} }
void collect_nodes() { void collect_nodes() {
for (const DeckRecord& record : *active_records_) { for (const DeckRecord& record : active_records_) {
if (record.keyword != "NODE") { if (record.keyword != "NODE") {
continue; continue;
} }
@@ -298,7 +237,7 @@ private:
} }
void collect_raw_sets( void collect_raw_sets(
const std::vector<DeckRecord>& records, const std::span<const DeckRecord> records,
const bool assembly_scope) { const bool assembly_scope) {
for (const DeckRecord& record : records) { for (const DeckRecord& record : records) {
const bool is_node_set = record.keyword == "NSET"; const bool is_node_set = record.keyword == "NSET";
@@ -343,7 +282,7 @@ private:
} }
void collect_sections() { void collect_sections() {
for (const DeckRecord& record : *active_records_) { for (const DeckRecord& record : active_records_) {
if (record.keyword != "BEAM GENERAL SECTION") { if (record.keyword != "BEAM GENERAL SECTION") {
continue; continue;
} }
@@ -422,7 +361,7 @@ private:
} }
void collect_elements() { void collect_elements() {
for (const DeckRecord& record : *active_records_) { for (const DeckRecord& record : active_records_) {
if (record.keyword != "ELEMENT") { if (record.keyword != "ELEMENT") {
continue; continue;
} }
@@ -661,8 +600,8 @@ private:
const ParsedDeck& deck_; const ParsedDeck& deck_;
DomainBuilder builder_; DomainBuilder builder_;
std::vector<Diagnostic> diagnostics_; std::vector<Diagnostic> diagnostics_;
const std::vector<DeckRecord>* active_records_ = nullptr; std::span<const DeckRecord> active_records_;
const ParsedAssembly* active_assembly_ = nullptr; std::span<const DeckRecord> assembly_records_;
std::string part_name_; std::string part_name_;
std::string instance_name_; std::string instance_name_;
LabelMap node_ids_; LabelMap node_ids_;
+14 -1
View File
@@ -118,6 +118,7 @@ add_test(
) )
add_executable(fesa_abaqus_parser_tests add_executable(fesa_abaqus_parser_tests
unit/io/abaqus/active_input_test.cpp
unit/io/abaqus/input_contract_test.cpp unit/io/abaqus/input_contract_test.cpp
unit/io/abaqus/parser_test.cpp unit/io/abaqus/parser_test.cpp
unit/io/abaqus/set_resolution_test.cpp unit/io/abaqus/set_resolution_test.cpp
@@ -175,6 +176,18 @@ add_test(
--gtest_filter=AssemblySet.* --gtest_filter=AssemblySet.*
) )
add_test(
NAME ActiveInput
COMMAND "$<TARGET_FILE:fesa_abaqus_parser_tests>"
--gtest_filter=ActiveInput.*
)
add_test(
NAME SemanticScope
COMMAND "$<TARGET_FILE:fesa_abaqus_parser_tests>"
--gtest_filter=SemanticScope.*
)
add_executable(fesa_deck_to_domain_tests add_executable(fesa_deck_to_domain_tests
integration/io/minimal_deck_to_domain_test.cpp integration/io/minimal_deck_to_domain_test.cpp
) )
@@ -200,7 +213,7 @@ add_test(
) )
add_test( add_test(
NAME ActiveInstance NAME SingleInstance
COMMAND "$<TARGET_FILE:fesa_deck_to_domain_tests>" COMMAND "$<TARGET_FILE:fesa_deck_to_domain_tests>"
--gtest_filter=ActiveInstance.* --gtest_filter=ActiveInstance.*
) )
@@ -219,7 +219,7 @@ TEST(ActiveInstance, RejectsInstanceTransformWithSourceDiagnostic) {
ASSERT_TRUE(has_diagnostic(result, "abaqus.semantic.instance_transform")); ASSERT_TRUE(has_diagnostic(result, "abaqus.semantic.instance_transform"));
ASSERT_FALSE(result.diagnostics.empty()); ASSERT_FALSE(result.diagnostics.empty());
ASSERT_TRUE(result.diagnostics.front().source.has_value()); ASSERT_TRUE(result.diagnostics.front().source.has_value());
EXPECT_EQ(result.diagnostics.front().source->line, 4U); EXPECT_EQ(result.diagnostics.front().source->line, 5U);
} }
TEST(ActiveInstance, RejectsMissingPartReferenceWithSourceDiagnostic) { TEST(ActiveInstance, RejectsMissingPartReferenceWithSourceDiagnostic) {
+213
View File
@@ -0,0 +1,213 @@
#include <fesa/io/abaqus/active_input.hpp>
#include <fesa/io/abaqus/parser.hpp>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <system_error>
#include <gtest/gtest.h>
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<std::streamsize>(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::ParsedDeck parse(const std::filesystem::path& path) {
auto parsed = fesa::parse_deck(path);
if (!parsed.deck.has_value()) {
throw std::runtime_error{"Test deck did not parse."};
}
return std::move(*parsed.deck);
}
fesa::Diagnostic expect_failure(
const fesa::ParsedDeck& deck,
const std::string_view code,
const std::size_t line) {
const fesa::ActiveInputResult result = fesa::select_active_input(deck);
EXPECT_FALSE(result.input.has_value());
const auto diagnostic = std::ranges::find(
result.diagnostics, code, &fesa::Diagnostic::code);
if (diagnostic == result.diagnostics.end()) {
throw std::runtime_error{"Expected active-input diagnostic was not found."};
}
EXPECT_EQ(diagnostic->stage, fesa::DiagnosticStage::semantic);
EXPECT_TRUE(diagnostic->source.has_value());
if (diagnostic->source.has_value()) {
EXPECT_EQ(diagnostic->source->line, line);
}
return *diagnostic;
}
TEST(ActiveInput, SelectsFlatGlobalRecords) {
const fesa::ParsedDeck deck =
parse(fixture_path("minimal_cantilever.inp"));
const fesa::ActiveInputResult result = fesa::select_active_input(deck);
ASSERT_TRUE(result.input.has_value());
EXPECT_TRUE(result.diagnostics.empty());
EXPECT_TRUE(result.input->flat);
EXPECT_TRUE(result.input->part_name.empty());
EXPECT_TRUE(result.input->instance_name.empty());
EXPECT_EQ(result.input->part_records.data(), deck.global_records.data());
EXPECT_EQ(result.input->part_records.size(), deck.global_records.size());
EXPECT_TRUE(result.input->assembly_records.empty());
}
TEST(ActiveInput, SelectsOnlyTheReferencedPartAndAssemblyRecords) {
const TemporaryDeck input{
"fesa-active-input-selection.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"
"*END PART\n"
"*ASSEMBLY, NAME=RootAssembly\n"
"*INSTANCE, NAME=Beam-1, PART=BeamPart\n"
"*END INSTANCE\n"
"*NSET, NSET=Fixed, INSTANCE=Beam-1\n"
"1\n"
"*END ASSEMBLY\n"};
const fesa::ParsedDeck deck = parse(input.path());
const fesa::ActiveInputResult result = fesa::select_active_input(deck);
ASSERT_TRUE(result.input.has_value());
EXPECT_FALSE(result.input->flat);
EXPECT_EQ(result.input->part_name, "BeamPart");
EXPECT_EQ(result.input->instance_name, "Beam-1");
ASSERT_EQ(result.input->part_records.size(), 1U);
EXPECT_EQ(result.input->part_records[0].keyword, "NODE");
ASSERT_EQ(result.input->assembly_records.size(), 1U);
EXPECT_EQ(result.input->assembly_records[0].keyword, "NSET");
}
TEST(ActiveInput, RejectsMixedFlatAndHierarchicalMeshAtTheFlatRecord) {
const fesa::ParsedDeck deck =
parse(fixture_path("invalid/mixed_mesh.inp"));
const fesa::Diagnostic diagnostic = expect_failure(
deck, "abaqus.semantic.mixed_mesh_organization", 1U);
EXPECT_EQ(diagnostic.source->file, fixture_path("invalid/mixed_mesh.inp"));
}
TEST(ActiveInput, RejectsHierarchicalInputWithoutAnAssembly) {
const TemporaryDeck input{
"fesa-missing-assembly.inp",
"*PART, NAME=BeamPart\n"
"*END PART\n"};
const fesa::ParsedDeck deck = parse(input.path());
expect_failure(deck, "abaqus.semantic.assembly_count", 1U);
}
TEST(ActiveInput, RejectsAnAssemblyWithoutExactlyOneInstance) {
const TemporaryDeck no_instance{
"fesa-no-instance.inp",
"*PART, NAME=BeamPart\n"
"*END PART\n"
"*ASSEMBLY, NAME=RootAssembly\n"
"*END ASSEMBLY\n"};
const TemporaryDeck multiple_instances{
"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"};
expect_failure(
parse(no_instance.path()), "abaqus.semantic.instance_count", 3U);
expect_failure(
parse(multiple_instances.path()),
"abaqus.semantic.instance_count",
6U);
}
TEST(ActiveInput, RejectsTransformAtTheFirstTransformDataRow) {
const fesa::ParsedDeck deck =
parse(fixture_path("invalid/instance_transform.inp"));
expect_failure(deck, "abaqus.semantic.instance_transform", 5U);
}
TEST(ActiveInput, RejectsAnInstanceThatReferencesAMissingPart) {
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 fesa::ParsedDeck deck = parse(input.path());
expect_failure(deck, "abaqus.semantic.missing_part", 4U);
}
TEST(ActiveInput, RejectsAssemblySetsForAnotherInstance) {
const fesa::ParsedDeck deck =
parse(fixture_path("invalid/assembly_set_wrong_instance.inp"));
expect_failure(deck, "abaqus.semantic.wrong_instance", 6U);
}
TEST(SemanticScope, RejectsInstanceLocalMeshAtTheKeywordRow) {
const auto path = fixture_path("invalid/instance_local_mesh.inp");
const fesa::ParseDeckResult result = fesa::parse_deck(path);
EXPECT_FALSE(result.deck.has_value());
ASSERT_EQ(result.diagnostics.size(), 1U);
EXPECT_EQ(result.diagnostics[0].stage, fesa::DiagnosticStage::syntax);
EXPECT_EQ(
result.diagnostics[0].code,
"abaqus.syntax.instance_local_keyword");
ASSERT_TRUE(result.diagnostics[0].source.has_value());
EXPECT_EQ(result.diagnostics[0].source->file, path);
EXPECT_EQ(result.diagnostics[0].source->line, 5U);
}
} // namespace