diff --git a/phases/abaqus-subset-completion/index.json b/phases/abaqus-subset-completion/index.json index 3849b2e..21b4ac5 100644 --- a/phases/abaqus-subset-completion/index.json +++ b/phases/abaqus-subset-completion/index.json @@ -6,7 +6,7 @@ "step": 0, "name": "abaqus-input-contract", "status": "completed", - "summary": "Defined the normative Phase 1 Abaqus subset and a 53-case public parser/mapper fixture matrix; 28 cases pass and 25 expected failures remain for Steps 1-4.", + "summary": "Defined the normative Phase 1 Abaqus subset and expanded the public parser/mapper fixture matrix to 69 cases covering strict scopes, parameter forms, data ownership, exact mesh rows, duplicate Parts, Assembly-only hierarchical targets, and source-accurate diagnostics.", "started_at": "2026-08-01T02:16:39+0900", "completed_at": "2026-08-01T02:24:23+0900" }, @@ -45,4 +45,4 @@ ], "created_at": "2026-08-01T01:59:08+0900", "completed_at": "2026-08-01T03:23:25+0900" -} \ No newline at end of file +} diff --git a/src/fesa/io/abaqus/active_input.cpp b/src/fesa/io/abaqus/active_input.cpp index 3be7c63..2d9b18f 100644 --- a/src/fesa/io/abaqus/active_input.cpp +++ b/src/fesa/io/abaqus/active_input.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -15,8 +16,11 @@ const std::string* parameter( return found == record.parameters.end() ? nullptr : &found->second; } -bool is_mesh_record(const DeckRecord& record) { - return record.keyword == "NODE" || record.keyword == "ELEMENT"; +bool is_flat_model_record(const DeckRecord& record) { + return record.keyword == "NODE" || record.keyword == "ELEMENT" || + record.keyword == "NSET" || record.keyword == "ELSET" || + record.keyword == "BEAM GENERAL SECTION" || + record.keyword == "TRANSVERSE SHEAR STIFFNESS"; } ActiveInputResult failure( @@ -53,13 +57,23 @@ ActiveInputResult select_active_input(const ParsedDeck& deck) { }; } - const auto flat_mesh = - std::ranges::find_if(deck.global_records, is_mesh_record); - if (flat_mesh != deck.global_records.end()) { + const auto flat_record = + std::ranges::find_if(deck.global_records, is_flat_model_record); + if (flat_record != deck.global_records.end()) { return failure( "abaqus.semantic.mixed_mesh_organization", - "Flat mesh records cannot be mixed with Part/Assembly input.", - flat_mesh->source); + "Flat model records cannot be mixed with Part/Assembly input.", + flat_record->source); + } + + std::set> part_names; + for (const ParsedPart& part : deck.parts) { + if (!part_names.insert(part.name).second) { + return failure( + "abaqus.semantic.duplicate_part", + "Part name '" + part.name + "' is defined more than once.", + part.source); + } } if (!deck.assembly.has_value()) { diff --git a/src/fesa/io/abaqus/parser.cpp b/src/fesa/io/abaqus/parser.cpp index c648446..5234893 100644 --- a/src/fesa/io/abaqus/parser.cpp +++ b/src/fesa/io/abaqus/parser.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,7 @@ enum class Scope { global, part, assembly, instance, step }; struct KeywordLine final { std::string keyword; std::map> parameters; + std::set> flag_parameters; SourceLocation source; }; @@ -188,6 +190,38 @@ std::optional validate_parameter_names( return std::nullopt; } +std::optional unsupported_parameter_value( + const KeywordLine& keyword, + std::string_view name); + +std::optional validate_parameter_schema( + const KeywordLine& keyword, + const std::initializer_list valued, + const std::initializer_list flags = {}) { + for (const auto& [name, value] : keyword.parameters) { + if (is_allowed_parameter(name, valued)) { + if (keyword.flag_parameters.contains(name) || value.empty()) { + return syntax_failure( + "abaqus.syntax.invalid_parameter", + "Parameter '" + name + "' on *" + keyword.keyword + + " requires a nonempty value.", + keyword.source); + } + } else if (is_allowed_parameter(name, flags)) { + if (!keyword.flag_parameters.contains(name)) { + return unsupported_parameter_value(keyword, name); + } + } else { + return syntax_failure( + "abaqus.syntax.unsupported_parameter", + "Unsupported parameter '" + name + "' on *" + + keyword.keyword + ".", + keyword.source); + } + } + return std::nullopt; +} + std::optional unsupported_parameter_value( const KeywordLine& keyword, const std::string_view name) { @@ -201,10 +235,10 @@ std::optional unsupported_parameter_value( std::optional validate_noop_parameters( const KeywordLine& keyword) { if (keyword.keyword == "HEADING") { - return validate_parameter_names(keyword, {}); + return validate_parameter_schema(keyword, {}); } if (keyword.keyword == "PREPRINT") { - if (auto error = validate_parameter_names( + if (auto error = validate_parameter_schema( keyword, {"ECHO", "MODEL", "HISTORY", "CONTACT"})) { return error; } @@ -217,8 +251,8 @@ std::optional validate_noop_parameters( return std::nullopt; } if (keyword.keyword == "RESTART") { - if (auto error = - validate_parameter_names(keyword, {"WRITE", "FREQUENCY"})) { + if (auto error = validate_parameter_schema( + keyword, {"FREQUENCY"}, {"WRITE"})) { return error; } if (const std::string* write = parameter(keyword, "WRITE"); @@ -239,8 +273,8 @@ std::optional validate_noop_parameters( return std::nullopt; } if (keyword.keyword == "OUTPUT") { - if (auto error = validate_parameter_names( - keyword, {"FIELD", "HISTORY", "VARIABLE"})) { + if (auto error = validate_parameter_schema( + keyword, {"VARIABLE"}, {"FIELD", "HISTORY"})) { return error; } const bool field = keyword.parameters.contains("FIELD"); @@ -280,6 +314,7 @@ std::optional parse_keyword_line( KeywordLine parsed{ uppercase_ascii(fields[0]), {}, + {}, source, }; for (std::size_t index = 1; index < fields.size(); ++index) { @@ -312,6 +347,9 @@ std::optional parse_keyword_line( source); return std::nullopt; } + if (equals == std::string_view::npos) { + parsed.flag_parameters.insert(key); + } } return parsed; } @@ -418,8 +456,8 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { "*STEP is only valid in global input scope.", source); } - if (auto error = - validate_parameter_names(keyword, {"NAME", "NLGEOM"})) { + if (auto error = validate_parameter_schema( + keyword, {"NAME", "NLGEOM"})) { return std::move(*error); } if (const std::string* name = parameter(keyword, "NAME"); @@ -481,6 +519,9 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { "*PART is only valid in global input scope.", source); } + if (auto error = validate_parameter_schema(keyword, {"NAME"})) { + return std::move(*error); + } const std::string* name = parameter(keyword, "NAME"); if (name == nullptr || name->empty()) { return missing_parameter(keyword, "NAME"); @@ -497,6 +538,9 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { "*END PART does not match an open *PART.", source); } + if (auto error = validate_parameter_schema(keyword, {})) { + return std::move(*error); + } deck.parts.push_back(std::move(*current_part)); current_part.reset(); scope = Scope::global; @@ -510,6 +554,9 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { "*ASSEMBLY is only valid in global input scope.", source); } + if (auto error = validate_parameter_schema(keyword, {"NAME"})) { + return std::move(*error); + } if (deck.assembly.has_value() || current_assembly.has_value()) { return syntax_failure( @@ -534,6 +581,9 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { "*END ASSEMBLY does not match an open *ASSEMBLY.", source); } + if (auto error = validate_parameter_schema(keyword, {})) { + return std::move(*error); + } deck.assembly = std::move(*current_assembly); current_assembly.reset(); scope = Scope::global; @@ -548,6 +598,10 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { "*INSTANCE is only valid in an open *ASSEMBLY.", source); } + if (auto error = + validate_parameter_schema(keyword, {"NAME", "PART"})) { + return std::move(*error); + } const std::string* name = parameter(keyword, "NAME"); if (name == nullptr || name->empty()) { return missing_parameter(keyword, "NAME"); @@ -571,6 +625,9 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { "*END INSTANCE does not match an open *INSTANCE.", source); } + if (auto error = validate_parameter_schema(keyword, {})) { + return std::move(*error); + } current_assembly->instances.push_back( std::move(*current_instance)); current_instance.reset(); @@ -668,6 +725,59 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { keyword.keyword != "BOUNDARY" && keyword.keyword != "CLOAD") { return invalid_record_scope(keyword); } + if (scope != Scope::instance) { + const bool mesh_scope = scope == Scope::global || scope == Scope::part; + const bool set_scope = mesh_scope || scope == Scope::assembly; + if ((keyword.keyword == "NODE" || keyword.keyword == "ELEMENT" || + keyword.keyword == "BEAM GENERAL SECTION" || + keyword.keyword == "TRANSVERSE SHEAR STIFFNESS") && + !mesh_scope) { + return invalid_record_scope(keyword); + } + if ((keyword.keyword == "NSET" || keyword.keyword == "ELSET") && + !set_scope) { + return invalid_record_scope(keyword); + } + if ((keyword.keyword == "MATERIAL" || + keyword.keyword == "ELASTIC") && + scope != Scope::global) { + return invalid_record_scope(keyword); + } + } + if (scope != Scope::instance) { + std::optional parameter_error; + if (keyword.keyword == "NODE" || keyword.keyword == "ELASTIC" || + keyword.keyword == "TRANSVERSE SHEAR STIFFNESS") { + parameter_error = validate_parameter_schema(keyword, {}); + } else if (keyword.keyword == "ELEMENT") { + parameter_error = validate_parameter_schema( + keyword, {"TYPE", "ELSET"}); + } else if (keyword.keyword == "NSET") { + parameter_error = scope == Scope::assembly + ? validate_parameter_schema( + keyword, + {"NSET", "INSTANCE"}, + {"GENERATE"}) + : validate_parameter_schema( + keyword, {"NSET"}, {"GENERATE"}); + } else if (keyword.keyword == "ELSET") { + parameter_error = scope == Scope::assembly + ? validate_parameter_schema( + keyword, + {"ELSET", "INSTANCE"}, + {"GENERATE"}) + : validate_parameter_schema( + keyword, {"ELSET"}, {"GENERATE"}); + } else if (keyword.keyword == "MATERIAL") { + parameter_error = validate_parameter_schema(keyword, {"NAME"}); + } else if (keyword.keyword == "BEAM GENERAL SECTION") { + parameter_error = validate_parameter_schema( + keyword, {"SECTION", "ELSET", "MATERIAL"}); + } + if (parameter_error.has_value()) { + return std::move(*parameter_error); + } + } DeckRecord next_record{ std::move(keyword.keyword), @@ -678,7 +788,9 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { switch (scope) { case Scope::global: deck.global_records.push_back(std::move(next_record)); - current_record = &deck.global_records.back(); + if (deck.global_records.back().keyword != "MATERIAL") { + current_record = &deck.global_records.back(); + } break; case Scope::part: current_part->records.push_back(std::move(next_record)); diff --git a/src/fesa/io/abaqus/semantic_mapper.cpp b/src/fesa/io/abaqus/semantic_mapper.cpp index b11da96..d9ff9a4 100644 --- a/src/fesa/io/abaqus/semantic_mapper.cpp +++ b/src/fesa/io/abaqus/semantic_mapper.cpp @@ -195,7 +195,7 @@ private: std::optional parse_real( const std::string_view text, - const DeckRecord& record, + const SourceLocation& source, const std::string_view purpose) { double value = 0.0; const auto parsed = std::from_chars( @@ -209,7 +209,7 @@ private: "abaqus.semantic.invalid_number", "Invalid " + std::string{purpose} + " value '" + std::string{text} + "'.", - record.source); + source); return std::nullopt; } return value; @@ -323,11 +323,22 @@ private: if (record.keyword != "NODE") { continue; } + if (record.data.empty()) { + add_error( + "abaqus.semantic.invalid_node_data", + "*NODE requires at least one data row.", + record.source); + continue; + } for (std::size_t row_index = 0; row_index < record.data.size(); ++row_index) { const auto& row = record.data[row_index]; const SourceLocation source = data_source(record, row_index); - if (row.size() < 4U) { + if (row.size() != 4U || std::ranges::any_of( + row, + [](const std::string& field) { + return field.empty(); + })) { add_error( "abaqus.semantic.invalid_node_data", "*NODE requires a label and three coordinates.", @@ -337,9 +348,9 @@ private: const auto label = parse_label(row[0], "node", source); - 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"); + const auto x = parse_real(row[1], source, "node coordinate"); + const auto y = parse_real(row[2], source, "node coordinate"); + const auto z = parse_real(row[3], source, "node coordinate"); if (!label.has_value() || !x.has_value() || !y.has_value() || !z.has_value()) { continue; @@ -385,6 +396,10 @@ private: std::vector& labels = target[set.set_name]; if (assembly_scope) { labels = set.sorted_unique_labels; + if (set.kind == ResolvedSetKind::node) { + raw_assembly_node_sets_[set.set_name] = + set.sorted_unique_labels; + } } else { labels.insert( labels.end(), @@ -435,7 +450,7 @@ private: if (section_type == nullptr || element_set == nullptr || material_name == nullptr) { valid = false; - } else if (*section_type != "GENERAL") { + } else if (uppercase_ascii(*section_type) != "GENERAL") { add_error( "abaqus.semantic.unsupported_section", "Phase 1 supports only SECTION=GENERAL.", @@ -646,19 +661,30 @@ private: if (type == nullptr) { continue; } - if (*type != "B31") { + if (uppercase_ascii(*type) != "B31") { add_error( "abaqus.semantic.unsupported_element", "Phase 1 supports only B31 elements.", record.source); continue; } + if (record.data.empty()) { + add_error( + "abaqus.semantic.invalid_element_data", + "*ELEMENT requires at least one data row.", + record.source); + continue; + } for (std::size_t row_index = 0; row_index < record.data.size(); ++row_index) { const auto& row = record.data[row_index]; const SourceLocation source = data_source(record, row_index); - if (row.size() < 3U) { + if (row.size() != 3U || std::ranges::any_of( + row, + [](const std::string& field) { + return field.empty(); + })) { add_error( "abaqus.semantic.invalid_element_data", "B31 element data requires a label and two nodes.", @@ -701,7 +727,7 @@ private: add_error( "abaqus.semantic.missing_node", "B31 element references a missing node label.", - record.source); + source); continue; } @@ -751,6 +777,10 @@ private: node_sets_[name] = members; builder_.add_node_set({name, std::move(members)}); } + for (const auto& [name, labels] : raw_assembly_node_sets_) { + assembly_node_sets_[name] = + resolve_set(labels, node_ids_, name); + } for (const auto& [name, labels] : raw_element_sets_) { std::vector members = resolve_set(labels, element_ids_, name); @@ -764,15 +794,17 @@ private: std::int64_t label = 0; const auto parsed = std::from_chars(target.data(), target.data() + target.size(), label); - if (parsed.ec == std::errc{} && + if (flat_ && 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()) { + } else if (parsed.ec != std::errc{} || + parsed.ptr != target.data() + target.size()) { + const auto& target_sets = flat_ ? node_sets_ : assembly_node_sets_; + const auto found = target_sets.find(target); + if (found != target_sets.end()) { return found->second; } } @@ -1011,8 +1043,11 @@ private: std::map element_assignments_; std::set> assigned_element_set_names_; RawSetMap raw_node_sets_; + RawSetMap raw_assembly_node_sets_; RawSetMap raw_element_sets_; std::map, std::less<>> node_sets_; + std::map, std::less<>> + assembly_node_sets_; std::int64_t next_node_id_ = 0; std::int64_t next_element_id_ = 0; std::int64_t next_material_id_ = 0; diff --git a/tests/fixtures/abaqus/contract.tsv b/tests/fixtures/abaqus/contract.tsv index 5c33aff..aef928e 100644 --- a/tests/fixtures/abaqus/contract.tsv +++ b/tests/fixtures/abaqus/contract.tsv @@ -26,6 +26,7 @@ heading_noop_valid valid valid/noop_directives.inp - - 0 2 1 1 1 6 1 phase1_defa preprint_noop_valid valid valid/noop_directives.inp - - 0 2 1 1 1 6 1 phase1_default 0.8333333333333334 0.8333333333333334 Fixed:1 Beam:1 restart_noop_valid valid valid/noop_directives.inp - - 0 2 1 1 1 6 1 phase1_default 0.8333333333333334 0.8333333333333334 Fixed:1 Beam:1 output_noop_valid valid valid/noop_directives.inp - - 0 2 1 1 1 6 1 phase1_default 0.8333333333333334 0.8333333333333334 Fixed:1 Beam:1 +enum_case_insensitive_valid valid valid/case_insensitive_enums.inp - - 0 2 1 1 1 6 1 phase1_default 0.8333333333333334 0.8333333333333334 Fixed:1 Beam:1 node_duplicate_invalid invalid invalid/node_duplicate.inp semantic abaqus.semantic.duplicate_node_label 3 - - - - - - - - - - - element_type_invalid invalid invalid/element_wrong_type.inp semantic abaqus.semantic.unsupported_element 4 - - - - - - - - - - - part_unclosed_invalid invalid invalid/part_unclosed.inp syntax abaqus.syntax.unclosed_part 1 - - - - - - - - - - - @@ -52,3 +53,18 @@ preprint_parameter_invalid invalid invalid/preprint_invalid_parameter.inp syntax restart_scope_invalid invalid invalid/restart_outside_step.inp syntax abaqus.syntax.invalid_restart_scope 1 - - - - - - - - - - - output_parameter_invalid invalid invalid/output_invalid_parameter.inp syntax abaqus.syntax.unsupported_parameter 3 - - - - - - - - - - - unknown_keyword_invalid invalid invalid/unknown_include.inp syntax abaqus.unsupported_keyword 1 - - - - - - - - - - - +node_parameter_invalid invalid invalid/node_unsupported_parameter.inp syntax abaqus.syntax.unsupported_parameter 1 - - - - - - - - - - - +node_scope_invalid invalid invalid/node_wrong_scope.inp syntax abaqus.syntax.invalid_node_scope 2 - - - - - - - - - - - +material_scope_invalid invalid invalid/material_wrong_scope.inp syntax abaqus.syntax.invalid_material_scope 2 - - - - - - - - - - - +generate_form_invalid invalid invalid/generate_valued.inp syntax abaqus.syntax.unsupported_parameter 1 - - - - - - - - - - - +flat_set_instance_invalid invalid invalid/flat_set_instance.inp syntax abaqus.syntax.unsupported_parameter 1 - - - - - - - - - - - +global_set_hierarchy_invalid invalid invalid/global_set_hierarchical.inp semantic abaqus.semantic.mixed_mesh_organization 1 - - - - - - - - - - - +duplicate_part_invalid invalid invalid/duplicate_part.inp semantic abaqus.semantic.duplicate_part 3 - - - - - - - - - - - +node_surplus_field_invalid invalid invalid/node_surplus_field.inp semantic abaqus.semantic.invalid_node_data 2 - - - - - - - - - - - +node_empty_invalid invalid invalid/node_empty.inp semantic abaqus.semantic.invalid_node_data 1 - - - - - - - - - - - +element_surplus_field_invalid invalid invalid/element_surplus_field.inp semantic abaqus.semantic.invalid_element_data 2 - - - - - - - - - - - +element_empty_invalid invalid invalid/element_empty.inp semantic abaqus.semantic.invalid_element_data 1 - - - - - - - - - - - +hierarchical_part_target_invalid invalid invalid/hierarchical_part_set_target.inp semantic abaqus.semantic.missing_node_target 25 - - - - - - - - - - - +material_data_invalid invalid invalid/material_data.inp syntax abaqus.syntax.data_without_keyword 2 - - - - - - - - - - - +node_coordinate_invalid invalid invalid/node_invalid_coordinate.inp semantic abaqus.semantic.invalid_number 2 - - - - - - - - - - - +element_missing_node_invalid invalid invalid/element_missing_node.inp semantic abaqus.semantic.missing_node 4 - - - - - - - - - - - diff --git a/tests/fixtures/abaqus/invalid/duplicate_part.inp b/tests/fixtures/abaqus/invalid/duplicate_part.inp new file mode 100644 index 0000000..73e4722 --- /dev/null +++ b/tests/fixtures/abaqus/invalid/duplicate_part.inp @@ -0,0 +1,8 @@ +*PART, NAME=BeamPart +*END PART +*PART, NAME=BeamPart +*END PART +*ASSEMBLY, NAME=RootAssembly +*INSTANCE, NAME=Beam-1, PART=BeamPart +*END INSTANCE +*END ASSEMBLY diff --git a/tests/fixtures/abaqus/invalid/element_empty.inp b/tests/fixtures/abaqus/invalid/element_empty.inp new file mode 100644 index 0000000..6f3e29d --- /dev/null +++ b/tests/fixtures/abaqus/invalid/element_empty.inp @@ -0,0 +1,4 @@ +*ELEMENT, TYPE=B31 +*STEP +*STATIC +*END STEP diff --git a/tests/fixtures/abaqus/invalid/element_missing_node.inp b/tests/fixtures/abaqus/invalid/element_missing_node.inp new file mode 100644 index 0000000..9a5b52e --- /dev/null +++ b/tests/fixtures/abaqus/invalid/element_missing_node.inp @@ -0,0 +1,13 @@ +*NODE +1, 0.0, 0.0, 0.0 +*ELEMENT, TYPE=B31, ELSET=Beam +1, 1, 2 +*MATERIAL, NAME=Steel +*ELASTIC +210000.0, 0.3 +*BEAM GENERAL SECTION, SECTION=GENERAL, ELSET=Beam, MATERIAL=Steel +1.0, 1.0, 0.0, 1.0, 1.0 +0.0, 1.0, 0.0 +*STEP +*STATIC +*END STEP diff --git a/tests/fixtures/abaqus/invalid/element_surplus_field.inp b/tests/fixtures/abaqus/invalid/element_surplus_field.inp new file mode 100644 index 0000000..4d80507 --- /dev/null +++ b/tests/fixtures/abaqus/invalid/element_surplus_field.inp @@ -0,0 +1,5 @@ +*ELEMENT, TYPE=B31 +1, 1, 2, 3 +*STEP +*STATIC +*END STEP diff --git a/tests/fixtures/abaqus/invalid/flat_set_instance.inp b/tests/fixtures/abaqus/invalid/flat_set_instance.inp new file mode 100644 index 0000000..59d5458 --- /dev/null +++ b/tests/fixtures/abaqus/invalid/flat_set_instance.inp @@ -0,0 +1,2 @@ +*NSET, NSET=Fixed, INSTANCE=Beam-1 +1 diff --git a/tests/fixtures/abaqus/invalid/generate_valued.inp b/tests/fixtures/abaqus/invalid/generate_valued.inp new file mode 100644 index 0000000..bbdae7c --- /dev/null +++ b/tests/fixtures/abaqus/invalid/generate_valued.inp @@ -0,0 +1,2 @@ +*NSET, NSET=Generated, GENERATE=YES +1, 2, 1 diff --git a/tests/fixtures/abaqus/invalid/global_set_hierarchical.inp b/tests/fixtures/abaqus/invalid/global_set_hierarchical.inp new file mode 100644 index 0000000..38c8eed --- /dev/null +++ b/tests/fixtures/abaqus/invalid/global_set_hierarchical.inp @@ -0,0 +1,7 @@ +*NSET, NSET=Ignored +*PART, NAME=BeamPart +*END PART +*ASSEMBLY, NAME=RootAssembly +*INSTANCE, NAME=Beam-1, PART=BeamPart +*END INSTANCE +*END ASSEMBLY diff --git a/tests/fixtures/abaqus/invalid/hierarchical_part_set_target.inp b/tests/fixtures/abaqus/invalid/hierarchical_part_set_target.inp new file mode 100644 index 0000000..983735c --- /dev/null +++ b/tests/fixtures/abaqus/invalid/hierarchical_part_set_target.inp @@ -0,0 +1,26 @@ +*PART, NAME=BeamPart +*NODE +1, 0.0, 0.0, 0.0 +2, 1.0, 0.0, 0.0 +*ELEMENT, TYPE=B31, ELSET=Beam +1, 1, 2 +*NSET, NSET=Fixed +1 +*ELSET, ELSET=Beam +1 +*BEAM GENERAL SECTION, SECTION=GENERAL, ELSET=Beam, MATERIAL=Steel +1.0, 1.0, 0.0, 1.0, 1.0 +0.0, 1.0, 0.0 +*END PART +*ASSEMBLY, NAME=RootAssembly +*INSTANCE, NAME=Beam-1, PART=BeamPart +*END INSTANCE +*END ASSEMBLY +*MATERIAL, NAME=Steel +*ELASTIC +200.0, 0.25 +*STEP +*STATIC +*BOUNDARY +Fixed, 1, 6 +*END STEP diff --git a/tests/fixtures/abaqus/invalid/material_data.inp b/tests/fixtures/abaqus/invalid/material_data.inp new file mode 100644 index 0000000..fe09ff3 --- /dev/null +++ b/tests/fixtures/abaqus/invalid/material_data.inp @@ -0,0 +1,2 @@ +*MATERIAL, NAME=Steel +unexpected data diff --git a/tests/fixtures/abaqus/invalid/material_wrong_scope.inp b/tests/fixtures/abaqus/invalid/material_wrong_scope.inp new file mode 100644 index 0000000..e1ea2c5 --- /dev/null +++ b/tests/fixtures/abaqus/invalid/material_wrong_scope.inp @@ -0,0 +1,3 @@ +*PART, NAME=BeamPart +*MATERIAL, NAME=Steel +*END PART diff --git a/tests/fixtures/abaqus/invalid/node_empty.inp b/tests/fixtures/abaqus/invalid/node_empty.inp new file mode 100644 index 0000000..02db301 --- /dev/null +++ b/tests/fixtures/abaqus/invalid/node_empty.inp @@ -0,0 +1,4 @@ +*NODE +*STEP +*STATIC +*END STEP diff --git a/tests/fixtures/abaqus/invalid/node_invalid_coordinate.inp b/tests/fixtures/abaqus/invalid/node_invalid_coordinate.inp new file mode 100644 index 0000000..fa5929a --- /dev/null +++ b/tests/fixtures/abaqus/invalid/node_invalid_coordinate.inp @@ -0,0 +1,5 @@ +*NODE +1, invalid, 0.0, 0.0 +*STEP +*STATIC +*END STEP diff --git a/tests/fixtures/abaqus/invalid/node_surplus_field.inp b/tests/fixtures/abaqus/invalid/node_surplus_field.inp new file mode 100644 index 0000000..61bebab --- /dev/null +++ b/tests/fixtures/abaqus/invalid/node_surplus_field.inp @@ -0,0 +1,5 @@ +*NODE +1, 0.0, 0.0, 0.0, 9.0 +*STEP +*STATIC +*END STEP diff --git a/tests/fixtures/abaqus/invalid/node_unsupported_parameter.inp b/tests/fixtures/abaqus/invalid/node_unsupported_parameter.inp new file mode 100644 index 0000000..cab4a5f --- /dev/null +++ b/tests/fixtures/abaqus/invalid/node_unsupported_parameter.inp @@ -0,0 +1,2 @@ +*NODE, EXTRA=1 +1, 0.0, 0.0, 0.0 diff --git a/tests/fixtures/abaqus/invalid/node_wrong_scope.inp b/tests/fixtures/abaqus/invalid/node_wrong_scope.inp new file mode 100644 index 0000000..e64045d --- /dev/null +++ b/tests/fixtures/abaqus/invalid/node_wrong_scope.inp @@ -0,0 +1,4 @@ +*ASSEMBLY, NAME=RootAssembly +*NODE +1, 0.0, 0.0, 0.0 +*END ASSEMBLY diff --git a/tests/fixtures/abaqus/minimal_cantilever.inp b/tests/fixtures/abaqus/minimal_cantilever.inp index bee41c4..661f8bc 100644 --- a/tests/fixtures/abaqus/minimal_cantilever.inp +++ b/tests/fixtures/abaqus/minimal_cantilever.inp @@ -4,7 +4,7 @@ 1, 0.0, 0.0, 0.0 ** Comments and blank lines do not terminate the current data record. -2, 1.0, 0.0, 0.0, +2, 1.0, 0.0, 0.0 *eLeMeNt, TYPE=B31, elset=Beam 1, 1, 2 *NSET, NSET=Fixed diff --git a/tests/fixtures/abaqus/valid/case_insensitive_enums.inp b/tests/fixtures/abaqus/valid/case_insensitive_enums.inp new file mode 100644 index 0000000..91c3b85 --- /dev/null +++ b/tests/fixtures/abaqus/valid/case_insensitive_enums.inp @@ -0,0 +1,22 @@ +*NODE +1, 0.0, 0.0, 0.0 +2, 1.0, 0.0, 0.0 +*ELEMENT, TYPE=b31, ELSET=Beam +1, 1, 2 +*NSET, NSET=Fixed +1 +*ELSET, ELSET=Beam +1 +*MATERIAL, NAME=Steel +*ELASTIC +200.0, 0.25 +*BEAM GENERAL SECTION, SECTION=general, ELSET=Beam, MATERIAL=Steel +1.0, 1.0, 0.0, 1.0, 1.0 +0.0, 1.0, 0.0 +*BOUNDARY +Fixed, 1, 6 +*STEP, NAME=Load +*STATIC +*CLOAD +2, 2, -1.0 +*END STEP diff --git a/tests/integration/io/minimal_deck_to_domain_test.cpp b/tests/integration/io/minimal_deck_to_domain_test.cpp index e4086c7..c9a11cb 100644 --- a/tests/integration/io/minimal_deck_to_domain_test.cpp +++ b/tests/integration/io/minimal_deck_to_domain_test.cpp @@ -173,6 +173,128 @@ TEST(DeckToDomain, NormalizesFlatAndSingleInstanceDecksEquivalently) { fesa::ShearPropertySource::phase1_default); } +TEST(DeckToDomain, RequiresExactNodeAndB31DataWidthsAndNonemptyRecords) { + struct Case final { + std::string_view name; + std::string_view mesh; + std::string_view code; + }; + const Case cases[]{ + { + "node-surplus-field", + "*NODE\n1, 0.0, 0.0, 0.0, 9.0\n" + "2, 1.0, 0.0, 0.0\n" + "*ELEMENT, TYPE=B31, ELSET=Beam\n1, 1, 2\n", + "abaqus.semantic.invalid_node_data", + }, + { + "node-empty", + "*NODE\n" + "*ELEMENT, TYPE=B31, ELSET=Beam\n1, 1, 2\n", + "abaqus.semantic.invalid_node_data", + }, + { + "element-surplus-field", + "*NODE\n1, 0.0, 0.0, 0.0\n2, 1.0, 0.0, 0.0\n" + "*ELEMENT, TYPE=B31, ELSET=Beam\n1, 1, 2, 3\n", + "abaqus.semantic.invalid_element_data", + }, + { + "element-empty", + "*NODE\n1, 0.0, 0.0, 0.0\n2, 1.0, 0.0, 0.0\n" + "*ELEMENT, TYPE=B31, ELSET=Beam\n", + "abaqus.semantic.invalid_element_data", + }, + }; + + for (const auto& test_case : cases) { + const TemporaryDeck input{ + "fesa-exact-mesh-data-" + std::string{test_case.name} + ".inp", + std::string{test_case.mesh} + + "*ELSET, ELSET=Beam\n1\n" + "*MATERIAL, NAME=Steel\n" + "*ELASTIC\n210000.0, 0.3\n" + "*BEAM GENERAL SECTION, SECTION=GENERAL, ELSET=Beam, " + "MATERIAL=Steel\n" + "1.0, 1.0, 0.0, 1.0, 1.0\n0.0, 1.0, 0.0\n" + "*STEP\n*STATIC\n*END STEP\n", + }; + + const auto result = parse_and_map(input.path()); + + SCOPED_TRACE(test_case.name); + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, test_case.code)); + } +} + +TEST(DeckToDomain, ReportsMeshDataErrorsAtTheOffendingRows) { + const TemporaryDeck invalid_coordinate{ + "fesa-node-coordinate-source.inp", + "*NODE\n" + "1, invalid, 0.0, 0.0\n" + "*STEP\n" + "*STATIC\n" + "*END STEP\n"}; + const TemporaryDeck missing_node{ + "fesa-element-node-source.inp", + "*NODE\n" + "1, 0.0, 0.0, 0.0\n" + "*ELEMENT, TYPE=B31, ELSET=Beam\n" + "1, 1, 2\n" + "*MATERIAL, NAME=Steel\n" + "*ELASTIC\n" + "210000.0, 0.3\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" + "*STEP\n" + "*STATIC\n" + "*END STEP\n"}; + + const auto coordinate_result = parse_and_map(invalid_coordinate.path()); + const auto missing_node_result = parse_and_map(missing_node.path()); + + const fesa::Diagnostic* coordinate = + find_diagnostic(coordinate_result, "abaqus.semantic.invalid_number"); + ASSERT_NE(coordinate, nullptr); + ASSERT_TRUE(coordinate->source.has_value()); + EXPECT_EQ(coordinate->source->line, 2U); + + const fesa::Diagnostic* node = + find_diagnostic(missing_node_result, "abaqus.semantic.missing_node"); + ASSERT_NE(node, nullptr); + ASSERT_TRUE(node->source.has_value()); + EXPECT_EQ(node->source->line, 4U); +} + +TEST(DeckToDomain, AcceptsCaseInsensitiveElementAndSectionValues) { + const TemporaryDeck input{ + "fesa-case-insensitive-enums.inp", + "*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" + "*MATERIAL, NAME=Steel\n" + "*ELASTIC\n" + "210000.0, 0.3\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" + "*STEP\n" + "*STATIC\n" + "*END STEP\n"}; + + const auto result = parse_and_map(input.path()); + + ASSERT_TRUE(result.domain.has_value()); + EXPECT_TRUE(result.diagnostics.empty()); + EXPECT_EQ(result.domain->beam_elements().size(), 1U); +} + TEST(ActiveInstance, ExcludesPartsNotReferencedByTheInstance) { const TemporaryDeck input{ "fesa-active-instance.inp", @@ -406,6 +528,42 @@ TEST(Boundary, CanonicalizesIdenticalGlobalAndStepPrescriptions) { } } +TEST(Boundary, HierarchicalTargetsRequireAssemblySets) { + const TemporaryDeck input{ + "fesa-hierarchical-part-set-target.inp", + "*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" + "*NSET, NSET=Fixed\n" + "1\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\n" + "*STATIC\n" + "*BOUNDARY\n" + "Fixed, 1, 6\n" + "*END STEP\n"}; + + const auto result = parse_and_map(input.path()); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "abaqus.semantic.missing_node_target")); +} + TEST(Boundary, ReportsConflictAtTheConflictingDataRow) { const auto result = parse_and_map(fixture_path("invalid/boundary_conflict.inp")); diff --git a/tests/unit/io/abaqus/active_input_test.cpp b/tests/unit/io/abaqus/active_input_test.cpp index 292d440..7fe3aa2 100644 --- a/tests/unit/io/abaqus/active_input_test.cpp +++ b/tests/unit/io/abaqus/active_input_test.cpp @@ -130,6 +130,37 @@ TEST(ActiveInput, RejectsMixedFlatAndHierarchicalMeshAtTheFlatRecord) { EXPECT_EQ(diagnostic.source->file, fixture_path("invalid/mixed_mesh.inp")); } +TEST(ActiveInput, RejectsGlobalSetsInHierarchicalInput) { + const TemporaryDeck input{ + "fesa-global-set-with-hierarchy.inp", + "*NSET, NSET=Ignored\n" + "*PART, NAME=BeamPart\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=BeamPart\n" + "*END INSTANCE\n" + "*END ASSEMBLY\n"}; + + expect_failure( + parse(input.path()), "abaqus.semantic.mixed_mesh_organization", 1U); +} + +TEST(ActiveInput, RejectsDuplicatePartNamesAtTheSecondDefinition) { + const TemporaryDeck input{ + "fesa-duplicate-parts.inp", + "*PART, NAME=BeamPart\n" + "*END PART\n" + "*PART, NAME=BeamPart\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=BeamPart\n" + "*END INSTANCE\n" + "*END ASSEMBLY\n"}; + + expect_failure( + parse(input.path()), "abaqus.semantic.duplicate_part", 3U); +} + TEST(ActiveInput, RejectsHierarchicalInputWithoutAnAssembly) { const TemporaryDeck input{ "fesa-missing-assembly.inp", diff --git a/tests/unit/io/abaqus/parser_test.cpp b/tests/unit/io/abaqus/parser_test.cpp index 7692f3f..c878faf 100644 --- a/tests/unit/io/abaqus/parser_test.cpp +++ b/tests/unit/io/abaqus/parser_test.cpp @@ -74,7 +74,7 @@ TEST(AbaqusParser, ParsesCaseInsensitiveKeywordsCommentsAndCommaFields) { EXPECT_EQ(nodes.data[0], (std::vector{ "1", "0.0", "0.0", "0.0"})); EXPECT_EQ(nodes.data[1], (std::vector{ - "2", "1.0", "0.0", "0.0", ""})); + "2", "1.0", "0.0", "0.0"})); EXPECT_EQ(nodes.source.file, path); EXPECT_EQ(nodes.source.line, 3U); EXPECT_EQ(nodes.source.column, 1U); @@ -121,6 +121,214 @@ TEST(AbaqusParser, RejectsUnsupportedKeywordsInsteadOfIgnoringThem) { EXPECT_EQ(result.diagnostics[0].source->line, 2U); } +TEST(AbaqusParser, RejectsUnsupportedParametersOnSupportedKeywords) { + struct Case final { + std::string_view name; + std::string_view contents; + std::size_t line; + }; + const Case cases[]{ + {"node", "*NODE, EXTRA=1\n1, 0.0, 0.0, 0.0\n", 1U}, + {"element", "*ELEMENT, TYPE=B31, EXTRA=1\n1, 1, 2\n", 1U}, + {"part", "*PART, NAME=P, EXTRA=1\n*END PART\n", 1U}, + {"end-part", "*PART, NAME=P\n*END PART, EXTRA=1\n", 2U}, + { + "assembly", + "*ASSEMBLY, NAME=A, EXTRA=1\n*END ASSEMBLY\n", + 1U, + }, + { + "end-assembly", + "*ASSEMBLY, NAME=A\n*END ASSEMBLY, EXTRA=1\n", + 2U, + }, + { + "instance", + "*ASSEMBLY, NAME=A\n" + "*INSTANCE, NAME=I, PART=P, EXTRA=1\n" + "*END INSTANCE\n" + "*END ASSEMBLY\n", + 2U, + }, + { + "end-instance", + "*ASSEMBLY, NAME=A\n" + "*INSTANCE, NAME=I, PART=P\n" + "*END INSTANCE, EXTRA=1\n" + "*END ASSEMBLY\n", + 3U, + }, + {"nset", "*NSET, NSET=S, EXTRA=1\n1\n", 1U}, + {"elset", "*ELSET, ELSET=S, EXTRA=1\n1\n", 1U}, + {"material", "*MATERIAL, NAME=M, EXTRA=1\n", 1U}, + { + "elastic", + "*MATERIAL, NAME=M\n*ELASTIC, EXTRA=1\n1.0, 0.3\n", + 2U, + }, + { + "beam-general-section", + "*BEAM GENERAL SECTION, SECTION=GENERAL, ELSET=S, " + "MATERIAL=M, EXTRA=1\n" + "1.0, 1.0, 0.0, 1.0, 1.0\n" + "0.0, 1.0, 0.0\n", + 1U, + }, + { + "transverse-shear-stiffness", + "*TRANSVERSE SHEAR STIFFNESS, EXTRA=1\n1.0, 1.0, 0.0\n", + 1U, + }, + }; + + for (const auto& test_case : cases) { + const TemporaryDeck input{ + "fesa-parser-unsupported-parameter-" + + std::string{test_case.name} + ".inp", + test_case.contents, + }; + + const auto result = fesa::parse_deck(input.path()); + + SCOPED_TRACE(test_case.name); + EXPECT_FALSE(result.deck.has_value()); + ASSERT_EQ(result.diagnostics.size(), 1U); + EXPECT_EQ( + result.diagnostics[0].code, + "abaqus.syntax.unsupported_parameter"); + ASSERT_TRUE(result.diagnostics[0].source.has_value()); + EXPECT_EQ(result.diagnostics[0].source->line, test_case.line); + } +} + +TEST(AbaqusParser, RejectsInvalidParameterFormsAndSetScopeOptions) { + struct Case final { + std::string_view name; + std::string_view contents; + std::string_view code; + }; + const Case cases[]{ + { + "generate-valued", + "*NSET, NSET=S, GENERATE=YES\n1, 2, 1\n", + "abaqus.syntax.unsupported_parameter", + }, + { + "generate-empty-valued", + "*NSET, NSET=S, GENERATE=\n1, 2, 1\n", + "abaqus.syntax.unsupported_parameter", + }, + { + "empty-element-set", + "*ELEMENT, TYPE=B31, ELSET=\n1, 1, 2\n", + "abaqus.syntax.invalid_parameter", + }, + { + "instance-on-flat-set", + "*NSET, NSET=S, INSTANCE=I\n1\n", + "abaqus.syntax.unsupported_parameter", + }, + { + "instance-on-part-set", + "*PART, NAME=P\n" + "*NSET, NSET=S, INSTANCE=I\n" + "1\n" + "*END PART\n", + "abaqus.syntax.unsupported_parameter", + }, + }; + + for (const auto& test_case : cases) { + const TemporaryDeck input{ + "fesa-parser-parameter-form-" + std::string{test_case.name} + + ".inp", + test_case.contents, + }; + + const auto result = fesa::parse_deck(input.path()); + + SCOPED_TRACE(test_case.name); + EXPECT_FALSE(result.deck.has_value()); + ASSERT_EQ(result.diagnostics.size(), 1U); + EXPECT_EQ(result.diagnostics[0].code, test_case.code); + ASSERT_TRUE(result.diagnostics[0].source.has_value()); + EXPECT_EQ(result.diagnostics[0].source->line, 1U + + (test_case.name == "instance-on-part-set" ? 1U : 0U)); + } +} + +TEST(AbaqusParser, RejectsModelKeywordsOutsideTheirDocumentedScopes) { + struct Case final { + std::string_view name; + std::string_view contents; + std::string_view code; + std::size_t line; + }; + const Case cases[]{ + { + "node-in-assembly", + "*ASSEMBLY, NAME=A\n" + "*NODE\n" + "1, 0.0, 0.0, 0.0\n" + "*END ASSEMBLY\n", + "abaqus.syntax.invalid_node_scope", + 2U, + }, + { + "material-in-part", + "*PART, NAME=P\n" + "*MATERIAL, NAME=M\n" + "*END PART\n", + "abaqus.syntax.invalid_material_scope", + 2U, + }, + { + "section-in-assembly", + "*ASSEMBLY, NAME=A\n" + "*BEAM GENERAL SECTION, SECTION=GENERAL, ELSET=S, MATERIAL=M\n" + "1.0, 1.0, 0.0, 1.0, 1.0\n" + "0.0, 1.0, 0.0\n" + "*END ASSEMBLY\n", + "abaqus.syntax.invalid_section_scope", + 2U, + }, + }; + + for (const auto& test_case : cases) { + const TemporaryDeck input{ + "fesa-parser-invalid-scope-" + std::string{test_case.name} + + ".inp", + test_case.contents, + }; + + const auto result = fesa::parse_deck(input.path()); + + SCOPED_TRACE(test_case.name); + EXPECT_FALSE(result.deck.has_value()); + ASSERT_EQ(result.diagnostics.size(), 1U); + EXPECT_EQ(result.diagnostics[0].code, test_case.code); + ASSERT_TRUE(result.diagnostics[0].source.has_value()); + EXPECT_EQ(result.diagnostics[0].source->line, test_case.line); + } +} + +TEST(AbaqusParser, RejectsDataRowsOnMaterial) { + const TemporaryDeck input{ + "fesa-parser-material-data.inp", + "*MATERIAL, NAME=Steel\n" + "unexpected data\n"}; + + const auto result = fesa::parse_deck(input.path()); + + EXPECT_FALSE(result.deck.has_value()); + ASSERT_EQ(result.diagnostics.size(), 1U); + EXPECT_EQ( + result.diagnostics[0].code, + "abaqus.syntax.data_without_keyword"); + ASSERT_TRUE(result.diagnostics[0].source.has_value()); + EXPECT_EQ(result.diagnostics[0].source->line, 2U); +} + TEST(ScopedDeck, PreservesPartAssemblyAndInstanceScopes) { const auto path = fixture_path("minimal_part_instance_cantilever.inp");