From 94c83a39c524912a17ad92eae1938943ddfb8815 Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Sat, 1 Aug 2026 03:23:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(abaqus-subset-completion):=20step=204=20?= =?UTF-8?q?=E2=80=94=20step-bc-load-and-noop-directives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fesa/io/abaqus/parser.cpp | 325 +++++++++++++++++- src/fesa/io/abaqus/semantic_mapper.cpp | 285 +++++++++++---- tests/CMakeLists.txt | 26 +- .../io/minimal_deck_to_domain_test.cpp | 217 ++++++++++++ 4 files changed, 785 insertions(+), 68 deletions(-) diff --git a/src/fesa/io/abaqus/parser.cpp b/src/fesa/io/abaqus/parser.cpp index 2820614..c648446 100644 --- a/src/fesa/io/abaqus/parser.cpp +++ b/src/fesa/io/abaqus/parser.cpp @@ -2,8 +2,11 @@ #include #include +#include #include +#include #include +#include #include #include #include @@ -13,7 +16,7 @@ namespace fesa { namespace { -enum class Scope { global, part, assembly, instance }; +enum class Scope { global, part, assembly, instance, step }; struct KeywordLine final { std::string keyword; @@ -21,6 +24,10 @@ struct KeywordLine final { SourceLocation source; }; +const std::string* parameter( + const KeywordLine& keyword, + std::string_view name); + std::string_view trim(const std::string_view value) { constexpr std::string_view whitespace{" \t\f\v\r\n"}; const std::size_t first = value.find_first_not_of(whitespace); @@ -93,17 +100,170 @@ bool is_supported_record(const std::string_view keyword) { std::string_view{"ELASTIC"}, std::string_view{"ELEMENT"}, std::string_view{"ELSET"}, - std::string_view{"END STEP"}, + std::string_view{"HEADING"}, std::string_view{"MATERIAL"}, std::string_view{"NODE"}, std::string_view{"NSET"}, + std::string_view{"OUTPUT"}, + std::string_view{"PREPRINT"}, + std::string_view{"RESTART"}, std::string_view{"STATIC"}, - std::string_view{"STEP"}, std::string_view{"TRANSVERSE SHEAR STIFFNESS"}, }; return std::ranges::find(supported, keyword) != supported.end(); } +bool is_known_keyword(const std::string_view keyword) { + constexpr std::array scope_keywords{ + std::string_view{"ASSEMBLY"}, + std::string_view{"END ASSEMBLY"}, + std::string_view{"END INSTANCE"}, + std::string_view{"END PART"}, + std::string_view{"INSTANCE"}, + std::string_view{"PART"}, + }; + return is_supported_record(keyword) || + std::ranges::find(scope_keywords, keyword) != scope_keywords.end(); +} + +ParseDeckResult invalid_record_scope(const KeywordLine& keyword) { + std::string code = "abaqus.syntax.invalid_step_scope"; + if (keyword.keyword == "NODE") { + code = "abaqus.syntax.invalid_node_scope"; + } else if (keyword.keyword == "ELEMENT") { + code = "abaqus.syntax.invalid_element_scope"; + } else if (keyword.keyword == "NSET" || keyword.keyword == "ELSET") { + code = "abaqus.syntax.invalid_set_scope"; + } else if (keyword.keyword == "MATERIAL" || + keyword.keyword == "ELASTIC") { + code = "abaqus.syntax.invalid_material_scope"; + } else if (keyword.keyword == "BEAM GENERAL SECTION") { + code = "abaqus.syntax.invalid_section_scope"; + } else if (keyword.keyword == "TRANSVERSE SHEAR STIFFNESS") { + code = "abaqus.syntax.invalid_transverse_shear_scope"; + } else if (keyword.keyword == "BOUNDARY") { + code = "abaqus.syntax.invalid_boundary_scope"; + } else if (keyword.keyword == "CLOAD") { + code = "abaqus.syntax.invalid_cload_scope"; + } else if (keyword.keyword == "HEADING") { + code = "abaqus.syntax.invalid_heading_scope"; + } else if (keyword.keyword == "PREPRINT") { + code = "abaqus.syntax.invalid_preprint_scope"; + } else if (keyword.keyword == "RESTART") { + code = "abaqus.syntax.invalid_restart_scope"; + } else if (keyword.keyword == "OUTPUT") { + code = "abaqus.syntax.invalid_output_scope"; + } else if (keyword.keyword == "PART") { + code = "abaqus.syntax.invalid_part_scope"; + } else if (keyword.keyword == "ASSEMBLY") { + code = "abaqus.syntax.invalid_assembly_scope"; + } else if (keyword.keyword == "INSTANCE") { + code = "abaqus.syntax.invalid_instance_scope"; + } + return syntax_failure( + std::move(code), + "*" + keyword.keyword + " is invalid in the current input scope.", + keyword.source); +} + +bool is_allowed_parameter( + const std::string_view name, + const std::initializer_list allowed) { + return std::ranges::find(allowed, name) != allowed.end(); +} + +std::optional validate_parameter_names( + const KeywordLine& keyword, + const std::initializer_list allowed) { + for (const auto& [name, value] : keyword.parameters) { + static_cast(value); + if (!is_allowed_parameter(name, allowed)) { + 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) { + return syntax_failure( + "abaqus.syntax.unsupported_parameter", + "Unsupported value for parameter '" + std::string{name} + + "' on *" + keyword.keyword + ".", + keyword.source); +} + +std::optional validate_noop_parameters( + const KeywordLine& keyword) { + if (keyword.keyword == "HEADING") { + return validate_parameter_names(keyword, {}); + } + if (keyword.keyword == "PREPRINT") { + if (auto error = validate_parameter_names( + keyword, {"ECHO", "MODEL", "HISTORY", "CONTACT"})) { + return error; + } + for (const auto& [name, value] : keyword.parameters) { + const std::string normalized = uppercase_ascii(value); + if (normalized != "YES" && normalized != "NO") { + return unsupported_parameter_value(keyword, name); + } + } + return std::nullopt; + } + if (keyword.keyword == "RESTART") { + if (auto error = + validate_parameter_names(keyword, {"WRITE", "FREQUENCY"})) { + return error; + } + if (const std::string* write = parameter(keyword, "WRITE"); + write != nullptr && !write->empty()) { + return unsupported_parameter_value(keyword, "WRITE"); + } + if (const std::string* frequency = parameter(keyword, "FREQUENCY"); + frequency != nullptr) { + std::int64_t value = 0; + const auto parsed = std::from_chars( + frequency->data(), frequency->data() + frequency->size(), value); + if (frequency->empty() || parsed.ec != std::errc{} || + parsed.ptr != frequency->data() + frequency->size() || + value < 0) { + return unsupported_parameter_value(keyword, "FREQUENCY"); + } + } + return std::nullopt; + } + if (keyword.keyword == "OUTPUT") { + if (auto error = validate_parameter_names( + keyword, {"FIELD", "HISTORY", "VARIABLE"})) { + return error; + } + const bool field = keyword.parameters.contains("FIELD"); + const bool history = keyword.parameters.contains("HISTORY"); + if (field == history) { + return unsupported_parameter_value(keyword, "FIELD/HISTORY"); + } + const std::string* flag = parameter( + keyword, field ? std::string_view{"FIELD"} + : std::string_view{"HISTORY"}); + if (flag == nullptr || !flag->empty()) { + return unsupported_parameter_value( + keyword, field ? "FIELD" : "HISTORY"); + } + if (const std::string* variable = parameter(keyword, "VARIABLE"); + variable != nullptr && uppercase_ascii(*variable) != "PRESELECT") { + return unsupported_parameter_value(keyword, "VARIABLE"); + } + return std::nullopt; + } + return std::nullopt; +} + std::optional parse_keyword_line( const std::string_view line, const SourceLocation& source, @@ -190,6 +350,8 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { std::optional current_part; std::optional current_assembly; std::optional current_instance; + std::optional current_step_source; + bool completed_step = false; DeckRecord* current_record = nullptr; std::string line; @@ -249,6 +411,69 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { KeywordLine& keyword = *parsed; current_record = nullptr; + if (keyword.keyword == "STEP") { + if (scope != Scope::global) { + return syntax_failure( + "abaqus.syntax.invalid_step_scope", + "*STEP is only valid in global input scope.", + source); + } + if (auto error = + validate_parameter_names(keyword, {"NAME", "NLGEOM"})) { + return std::move(*error); + } + if (const std::string* name = parameter(keyword, "NAME"); + name != nullptr && name->empty()) { + return syntax_failure( + "abaqus.syntax.invalid_parameter", + "*STEP parameter NAME requires a nonempty value.", + source); + } + if (const std::string* nlgeom = parameter(keyword, "NLGEOM"); + nlgeom != nullptr && nlgeom->empty()) { + return syntax_failure( + "abaqus.syntax.invalid_parameter", + "*STEP parameter NLGEOM requires a value.", + source); + } + deck.global_records.push_back({ + std::move(keyword.keyword), + std::move(keyword.parameters), + {}, + source, + }); + current_step_source = source; + scope = Scope::step; + continue; + } + + if (keyword.keyword == "END STEP") { + if (auto error = validate_parameter_names(keyword, {})) { + return std::move(*error); + } + if (scope != Scope::step || !current_step_source.has_value()) { + return syntax_failure( + "abaqus.syntax.unexpected_end_step", + "*END STEP does not match an open *STEP.", + source); + } + deck.global_records.push_back({ + std::move(keyword.keyword), + std::move(keyword.parameters), + {}, + source, + }); + current_step_source.reset(); + completed_step = true; + scope = Scope::global; + continue; + } + + if (scope == Scope::global && completed_step && + is_known_keyword(keyword.keyword)) { + return invalid_record_scope(keyword); + } + if (keyword.keyword == "PART") { if (scope != Scope::global) { return syntax_failure( @@ -353,12 +578,96 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { continue; } + if (keyword.keyword == "HEADING" || + keyword.keyword == "PREPRINT") { + if (scope != Scope::global) { + return syntax_failure( + keyword.keyword == "HEADING" + ? "abaqus.syntax.invalid_heading_scope" + : "abaqus.syntax.invalid_preprint_scope", + "*" + keyword.keyword + + " is only valid in global input scope.", + source); + } + if (auto error = validate_noop_parameters(keyword)) { + return std::move(*error); + } + deck.global_records.push_back({ + std::move(keyword.keyword), + std::move(keyword.parameters), + {}, + source, + }); + if (deck.global_records.back().keyword == "HEADING") { + current_record = &deck.global_records.back(); + } + continue; + } + + if (keyword.keyword == "RESTART" || keyword.keyword == "OUTPUT") { + if (scope != Scope::step) { + return syntax_failure( + keyword.keyword == "RESTART" + ? "abaqus.syntax.invalid_restart_scope" + : "abaqus.syntax.invalid_output_scope", + "*" + keyword.keyword + + " is only valid inside *STEP.", + source); + } + if (auto error = validate_noop_parameters(keyword)) { + return std::move(*error); + } + deck.global_records.push_back({ + std::move(keyword.keyword), + std::move(keyword.parameters), + {}, + source, + }); + continue; + } + + if (keyword.keyword == "STATIC") { + if (scope != Scope::step) { + return syntax_failure( + "abaqus.syntax.invalid_step_scope", + "*STATIC is only valid inside *STEP.", + source); + } + if (auto error = validate_parameter_names(keyword, {})) { + return std::move(*error); + } + } else if (keyword.keyword == "CLOAD") { + if (scope != Scope::step) { + return syntax_failure( + "abaqus.syntax.invalid_cload_scope", + "*CLOAD is only valid inside *STEP.", + source); + } + if (auto error = validate_parameter_names(keyword, {})) { + return std::move(*error); + } + } else if (keyword.keyword == "BOUNDARY") { + if (scope != Scope::global && scope != Scope::step) { + return syntax_failure( + "abaqus.syntax.invalid_boundary_scope", + "*BOUNDARY is valid only in global or Step scope.", + source); + } + if (auto error = validate_parameter_names(keyword, {})) { + return std::move(*error); + } + } + if (!is_supported_record(keyword.keyword)) { return syntax_failure( "abaqus.unsupported_keyword", "Unsupported Abaqus keyword *" + keyword.keyword + ".", source); } + if (scope == Scope::step && keyword.keyword != "STATIC" && + keyword.keyword != "BOUNDARY" && keyword.keyword != "CLOAD") { + return invalid_record_scope(keyword); + } DeckRecord next_record{ std::move(keyword.keyword), @@ -385,6 +694,10 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { "abaqus.syntax.instance_local_keyword", "Keyword records inside *INSTANCE are unsupported.", source); + case Scope::step: + deck.global_records.push_back(std::move(next_record)); + current_record = &deck.global_records.back(); + break; } } @@ -414,6 +727,12 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { "Abaqus *ASSEMBLY is not closed by *END ASSEMBLY.", current_assembly->source); } + if (current_step_source.has_value()) { + return syntax_failure( + "abaqus.syntax.unclosed_step", + "Abaqus *STEP is not closed by *END STEP.", + *current_step_source); + } return {std::move(deck), {}}; } diff --git a/src/fesa/io/abaqus/semantic_mapper.cpp b/src/fesa/io/abaqus/semantic_mapper.cpp index 795a9b1..b11da96 100644 --- a/src/fesa/io/abaqus/semantic_mapper.cpp +++ b/src/fesa/io/abaqus/semantic_mapper.cpp @@ -51,6 +51,16 @@ bool parse_number(const std::string_view text, double& value) { parsed.ptr == text.data() + text.size() && std::isfinite(value); } +std::string uppercase_ascii(std::string value) { + std::ranges::transform(value, value.begin(), [](const char character) { + if (character >= 'a' && character <= 'z') { + return static_cast(character - 'a' + 'A'); + } + return character; + }); + return value; +} + class DeckMapper final { public: explicit DeckMapper(const ParsedDeck& deck) : deck_{deck} {} @@ -141,8 +151,8 @@ private: std::optional parse_label( const std::string_view text, - const DeckRecord& record, - const std::string_view purpose) { + const std::string_view purpose, + const SourceLocation& source) { std::int64_t value = 0; const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value); @@ -152,28 +162,35 @@ private: "abaqus.semantic.invalid_label", "Invalid " + std::string{purpose} + " label '" + std::string{text} + "'.", - record.source); + source); return std::nullopt; } return value; } + std::optional parse_label( + const std::string_view text, + const DeckRecord& record, + const std::string_view purpose) { + return parse_label(text, purpose, record.source); + } + 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) { + const SourceLocation& source) { + 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 < 1 || + value > 6) { add_error( "abaqus.semantic.invalid_dof", "Phase 1 supports only degrees of freedom 1 through 6.", - record.source); + source); return std::nullopt; } - return static_cast(*value); + return static_cast(value); } std::optional parse_real( @@ -187,7 +204,7 @@ private: value, std::chars_format::general); if (parsed.ec != std::errc{} || - parsed.ptr != text.data() + text.size()) { + parsed.ptr != text.data() + text.size() || !std::isfinite(value)) { add_error( "abaqus.semantic.invalid_number", "Invalid " + std::string{purpose} + " value '" + @@ -306,16 +323,20 @@ private: if (record.keyword != "NODE") { continue; } - for (const auto& row : record.data) { + 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) { add_error( "abaqus.semantic.invalid_node_data", "*NODE requires a label and three coordinates.", - record.source); + source); continue; } - const auto label = parse_label(row[0], record, "node"); + 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"); @@ -324,6 +345,15 @@ private: continue; } + if (node_ids_.contains(*label)) { + add_error( + "abaqus.semantic.duplicate_node_label", + "Node label " + std::to_string(*label) + + " is defined more than once in its mesh scope.", + source); + continue; + } + const NodeId id{next_node_id_++}; node_ids_.emplace(*label, id); builder_.add_node({ @@ -353,12 +383,17 @@ private: ? raw_node_sets_ : raw_element_sets_; std::vector& labels = target[set.set_name]; - labels.insert( - labels.end(), - set.sorted_unique_labels.begin(), - set.sorted_unique_labels.end()); - std::ranges::sort(labels); - labels.erase(std::ranges::unique(labels).begin(), labels.end()); + if (assembly_scope) { + labels = set.sorted_unique_labels; + } else { + labels.insert( + labels.end(), + set.sorted_unique_labels.begin(), + set.sorted_unique_labels.end()); + std::ranges::sort(labels); + labels.erase( + std::ranges::unique(labels).begin(), labels.end()); + } if (mesh_scope && set.kind == ResolvedSetKind::element) { active_element_sets_[set.set_name] = @@ -619,24 +654,37 @@ private: continue; } - for (const auto& row : record.data) { + 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) { add_error( "abaqus.semantic.invalid_element_data", "B31 element data requires a label and two nodes.", - record.source); + source); continue; } - const auto label = parse_label(row[0], record, "element"); + const auto label = + parse_label(row[0], "element", source); const auto first_label = - parse_label(row[1], record, "node"); + parse_label(row[1], "node", source); const auto second_label = - parse_label(row[2], record, "node"); + parse_label(row[2], "node", source); if (!label.has_value() || !first_label.has_value() || !second_label.has_value()) { continue; } + if (element_ids_.contains(*label)) { + add_error( + "abaqus.semantic.duplicate_element_label", + "Element label " + std::to_string(*label) + + " is defined more than once in its mesh scope.", + source); + continue; + } + const auto assignment = element_assignments_.find(*label); if (assignment == element_assignments_.end()) { add_error( @@ -712,7 +760,7 @@ private: std::vector resolve_node_target( const std::string& target, - const DeckRecord& record) { + const SourceLocation& source) { std::int64_t label = 0; const auto parsed = std::from_chars(target.data(), target.data() + target.size(), label); @@ -732,46 +780,64 @@ private: add_error( "abaqus.semantic.missing_node_target", "Node target '" + target + "' does not resolve.", - record.source); + source); return {}; } void collect_boundary( const DeckRecord& record, - StepDefinition& step) { - for (const auto& row : record.data) { - if (row.size() < 2U) { + std::map, double>& + prescribed_values) { + 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() < 2U || row.size() > 4U || row[0].empty() || + row[1].empty()) { add_error( "abaqus.semantic.invalid_boundary_data", - "*BOUNDARY requires a target and degree of freedom.", - record.source); + "*BOUNDARY requires target, first DOF, optional last DOF, " + "and optional value.", + source); continue; } - const auto first_dof = parse_dof(row[1], record); + const auto first_dof = parse_dof(row[1], source); const auto last_dof = row.size() >= 3U && !row[2].empty() - ? parse_dof(row[2], record) + ? parse_dof(row[2], source) : 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()) { + double value = 0.0; + if (row.size() >= 4U && !row[3].empty() && + !parse_number(row[3], value)) { + add_error( + "abaqus.semantic.invalid_boundary_data", + "*BOUNDARY prescribed value must be finite.", + source); + continue; + } + if (!first_dof.has_value() || !last_dof.has_value()) { continue; } if (*last_dof < *first_dof) { add_error( "abaqus.semantic.invalid_dof_range", "*BOUNDARY degree-of-freedom range is reversed.", - record.source); + source); continue; } const std::vector nodes = - resolve_node_target(row[0], record); + resolve_node_target(row[0], source); for (const NodeId node : nodes) { for (std::uint8_t dof = *first_dof; dof <= *last_dof; ++dof) { - step.prescribed_dofs.push_back({node, dof, *value}); + const auto key = std::pair{node.value(), dof}; + const auto [existing, inserted] = + prescribed_values.emplace(key, value); + if (!inserted && existing->second != value) { + add_error( + "abaqus.semantic.conflicting_boundary", + "A node DOF has conflicting prescribed values.", + source); + } } } } @@ -780,57 +846,150 @@ private: void collect_loads( const DeckRecord& record, std::map>& loads) { - for (const auto& row : record.data) { - if (row.size() < 3U) { + 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 || row[0].empty() || row[1].empty() || + row[2].empty()) { add_error( "abaqus.semantic.invalid_cload_data", - "*CLOAD requires a target, degree of freedom, and value.", - record.source); + "*CLOAD requires exactly target, DOF, and magnitude.", + 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()) { + const auto dof = parse_dof(row[1], source); + double value = 0.0; + if (!parse_number(row[2], value)) { + add_error( + "abaqus.semantic.invalid_cload_data", + "*CLOAD magnitude must be finite.", + source); + continue; + } + if (!dof.has_value()) { continue; } const std::vector nodes = - resolve_node_target(row[0], record); + resolve_node_target(row[0], source); for (const NodeId node : nodes) { - loads[node.value()][static_cast(*dof - 1U)] += - *value; + double& component = + loads[node.value()][static_cast(*dof - 1U)]; + const double sum = component + value; + if (!std::isfinite(sum)) { + add_error( + "abaqus.semantic.invalid_cload_data", + "Accumulated *CLOAD magnitude must remain finite.", + source); + continue; + } + component = sum; } } } + void validate_static(const DeckRecord& record) { + if (record.data.empty()) { + return; + } + + const SourceLocation first_source = data_source(record, 0U); + if (record.data[0].empty() || record.data[0].size() > 4U || + std::ranges::any_of( + record.data[0], + [](const std::string& field) { return field.empty(); })) { + add_error( + "abaqus.semantic.invalid_static_data", + "*STATIC accepts one row of one through four positive values.", + first_source); + return; + } + + for (const std::string& field : record.data[0]) { + double value = 0.0; + if (!parse_number(field, value) || value <= 0.0) { + add_error( + "abaqus.semantic.invalid_static_data", + "*STATIC values must be finite and positive.", + first_source); + return; + } + } + if (record.data.size() > 1U) { + add_error( + "abaqus.semantic.invalid_static_data", + "*STATIC accepts only one data row.", + data_source(record, 1U)); + } + } + void collect_step() { StepDefinition step; + std::map, double> + prescribed_values; std::map> loads; - bool has_step = false; + const DeckRecord* first_step = nullptr; + std::size_t step_count = 0U; + std::size_t static_count = 0U; 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; + ++step_count; + if (first_step == nullptr) { + first_step = &record; + const std::string* name = parameter(record, "NAME"); + step.name = name == nullptr ? "Step-1" : *name; + } else { + add_error( + "abaqus.semantic.step_count", + "Phase 1 accepts exactly one *STEP.", + record.source); + } + + if (const std::string* nlgeom = parameter(record, "NLGEOM"); + nlgeom != nullptr && uppercase_ascii(*nlgeom) != "NO") { + add_error( + "abaqus.semantic.unsupported_step_option", + "Phase 1 supports only NLGEOM=NO.", + record.source); + } + } else if (record.keyword == "STATIC") { + ++static_count; + if (static_count > 1U) { + add_error( + "abaqus.semantic.invalid_static_data", + "A Phase 1 Step accepts exactly one *STATIC record.", + record.source); + } + validate_static(record); } else if (record.keyword == "BOUNDARY") { - collect_boundary(record, step); + collect_boundary(record, prescribed_values); } else if (record.keyword == "CLOAD") { collect_loads(record, loads); } } - if (!has_step) { + if (step_count == 0U) { 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.", + "abaqus.semantic.step_count", + "Phase 1 input requires exactly one *STEP.", source); return; } + if (step_count == 1U && static_count == 0U) { + add_error( + "abaqus.semantic.missing_static", + "The Phase 1 Step requires one *STATIC record.", + first_step->source); + } + for (const auto& [key, value] : prescribed_values) { + step.prescribed_dofs.push_back({NodeId{key.first}, key.second, value}); + } for (const auto& [node, values] : loads) { step.nodal_loads.push_back({NodeId{node}, values}); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 51e80f1..e35d231 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -156,8 +156,6 @@ add_test( COMMAND "$" --gtest_filter=AbaqusInputContract/* ) -# Steps 1-4 make this normative matrix pass, then remove WILL_FAIL. -set_tests_properties(AbaqusInputContract PROPERTIES WILL_FAIL TRUE) add_test( NAME SetResolution @@ -237,6 +235,30 @@ add_test( --gtest_filter=ActiveInstance.* ) +add_test( + NAME StepMapping + COMMAND "$" + --gtest_filter=StepMapping.* +) + +add_test( + NAME Boundary + COMMAND "$" + --gtest_filter=Boundary.* +) + +add_test( + NAME Cload + COMMAND "$" + --gtest_filter=Cload.* +) + +add_test( + NAME SuppliedCantilever + COMMAND "$" + --gtest_filter=SuppliedCantilever.* +) + add_executable(fesa_fem_primitives_tests unit/fem/beam_frame_test.cpp unit/fem/dof_manager_test.cpp diff --git a/tests/integration/io/minimal_deck_to_domain_test.cpp b/tests/integration/io/minimal_deck_to_domain_test.cpp index c7c79b2..e4086c7 100644 --- a/tests/integration/io/minimal_deck_to_domain_test.cpp +++ b/tests/integration/io/minimal_deck_to_domain_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -64,6 +65,14 @@ bool has_diagnostic( }); } +const fesa::Diagnostic* find_diagnostic( + const fesa::DomainBuildResult& result, + const std::string_view code) { + const auto found = std::ranges::find( + result.diagnostics, code, &fesa::Diagnostic::code); + return found == result.diagnostics.end() ? nullptr : &*found; +} + void expect_equivalent_analysis_data( const fesa::Domain& flat, const fesa::Domain& hierarchical) { @@ -265,4 +274,212 @@ TEST(ActiveInstance, RejectsMultipleInstances) { EXPECT_EQ(diagnostic->source->line, 6U); } +TEST(StepMapping, MapsOneStaticStepAndRejectsUnsupportedConfigurations) { + const auto valid = + parse_and_map(fixture_path("valid/noop_directives.inp")); + + ASSERT_TRUE(valid.domain.has_value()); + EXPECT_TRUE(valid.diagnostics.empty()); + EXPECT_EQ(valid.domain->step().name, "Step-1"); + + struct InvalidCase final { + std::string_view name; + std::string_view contents; + std::string_view code; + std::size_t line; + }; + const InvalidCase invalid_cases[]{ + { + "fesa-step-multiple.inp", + "*STEP, NAME=First\n" + "*STATIC\n" + "*END STEP\n" + "*STEP, NAME=Second\n" + "*STATIC\n" + "*END STEP\n", + "abaqus.semantic.step_count", + 4U, + }, + { + "fesa-step-nlgeom.inp", + "*STEP, NLGEOM=YES\n" + "*STATIC\n" + "*END STEP\n", + "abaqus.semantic.unsupported_step_option", + 1U, + }, + { + "fesa-static-invalid.inp", + "*STEP\n" + "*STATIC\n" + "1.0, -1.0\n" + "*END STEP\n", + "abaqus.semantic.invalid_static_data", + 3U, + }, + { + "fesa-static-extra-row.inp", + "*STEP\n" + "*STATIC\n" + "1.0\n" + "2.0\n" + "*END STEP\n", + "abaqus.semantic.invalid_static_data", + 4U, + }, + }; + + for (const InvalidCase& test_case : invalid_cases) { + const TemporaryDeck input{test_case.name, test_case.contents}; + const auto result = parse_and_map(input.path()); + + SCOPED_TRACE(test_case.name); + EXPECT_FALSE(result.domain.has_value()); + const fesa::Diagnostic* diagnostic = + find_diagnostic(result, test_case.code); + ASSERT_NE(diagnostic, nullptr); + ASSERT_TRUE(diagnostic->source.has_value()); + EXPECT_EQ(diagnostic->source->line, test_case.line); + } +} + +TEST(StepMapping, RejectsModelDataInsideStep) { + const TemporaryDeck input{ + "fesa-node-inside-step.inp", + "*STEP\n" + "*NODE\n" + "1, 0.0, 0.0, 0.0\n" + "*END STEP\n"}; + + const auto result = parse_and_map(input.path()); + + EXPECT_FALSE(result.domain.has_value()); + const fesa::Diagnostic* diagnostic = + find_diagnostic(result, "abaqus.syntax.invalid_node_scope"); + ASSERT_NE(diagnostic, nullptr); + ASSERT_TRUE(diagnostic->source.has_value()); + EXPECT_EQ(diagnostic->source->line, 2U); +} + +TEST(StepMapping, RejectsBoundaryAfterCompletedStep) { + const TemporaryDeck input{ + "fesa-boundary-after-step.inp", + "*STEP\n" + "*STATIC\n" + "*END STEP\n" + "*BOUNDARY\n" + "1, 1\n"}; + + const auto result = parse_and_map(input.path()); + + EXPECT_FALSE(result.domain.has_value()); + const fesa::Diagnostic* diagnostic = + find_diagnostic(result, "abaqus.syntax.invalid_boundary_scope"); + ASSERT_NE(diagnostic, nullptr); + ASSERT_TRUE(diagnostic->source.has_value()); + EXPECT_EQ(diagnostic->source->line, 4U); +} + +TEST(Boundary, CanonicalizesIdenticalGlobalAndStepPrescriptions) { + const TemporaryDeck input{ + "fesa-boundary-canonical.inp", + "*NODE\n" + "1, 0.0, 0.0, 0.0\n" + "*BOUNDARY\n" + "1, 1, 3, 2.5\n" + "*STEP\n" + "*STATIC\n" + "*BOUNDARY\n" + "1, 1, 3, 2.5\n" + "*END STEP\n"}; + + const auto result = parse_and_map(input.path()); + + ASSERT_TRUE(result.domain.has_value()); + EXPECT_TRUE(result.diagnostics.empty()); + const auto& prescribed = result.domain->step().prescribed_dofs; + ASSERT_EQ(prescribed.size(), 3U); + for (std::size_t index = 0; index < prescribed.size(); ++index) { + EXPECT_EQ(prescribed[index].node, fesa::NodeId{0}); + EXPECT_EQ(prescribed[index].dof, index + 1U); + EXPECT_DOUBLE_EQ(prescribed[index].value, 2.5); + } +} + +TEST(Boundary, ReportsConflictAtTheConflictingDataRow) { + const auto result = + parse_and_map(fixture_path("invalid/boundary_conflict.inp")); + + EXPECT_FALSE(result.domain.has_value()); + const fesa::Diagnostic* diagnostic = + find_diagnostic(result, "abaqus.semantic.conflicting_boundary"); + ASSERT_NE(diagnostic, nullptr); + ASSERT_TRUE(diagnostic->source.has_value()); + EXPECT_EQ(diagnostic->source->line, 10U); +} + +TEST(Cload, SumsForceAndMomentComponentsInInputOrder) { + const TemporaryDeck input{ + "fesa-cload-sum.inp", + "*NODE\n" + "1, 0.0, 0.0, 0.0\n" + "*STEP, NAME=Load\n" + "*STATIC\n" + "*CLOAD\n" + "1, 1, 2.0\n" + "1, 1, -0.5\n" + "1, 6, 4.0\n" + "*END STEP\n"}; + + const auto result = parse_and_map(input.path()); + + ASSERT_TRUE(result.domain.has_value()); + EXPECT_TRUE(result.diagnostics.empty()); + ASSERT_EQ(result.domain->step().nodal_loads.size(), 1U); + const auto& load = result.domain->step().nodal_loads.front(); + EXPECT_EQ(load.node, fesa::NodeId{0}); + EXPECT_EQ( + load.values, + (std::array{1.5, 0.0, 0.0, 0.0, 0.0, 4.0})); +} + +TEST(Cload, ReportsInvalidDofAtTheDataRow) { + const auto result = + parse_and_map(fixture_path("invalid/cload_invalid_dof.inp")); + + EXPECT_FALSE(result.domain.has_value()); + const fesa::Diagnostic* diagnostic = + find_diagnostic(result, "abaqus.semantic.invalid_dof"); + ASSERT_NE(diagnostic, nullptr); + ASSERT_TRUE(diagnostic->source.has_value()); + EXPECT_EQ(diagnostic->source->line, 6U); +} + +TEST(SuppliedCantilever, NormalizesReferenceModelThroughPublicParserAndMapper) { + const std::filesystem::path path = + std::filesystem::path{FESA_TEST_SOURCE_DIR}.parent_path() / + "reference" / "cantilever beam" / "cantilever beam.inp"; + + const auto result = parse_and_map(path); + + ASSERT_TRUE(result.domain.has_value()); + EXPECT_TRUE(result.diagnostics.empty()); + const fesa::Domain& domain = *result.domain; + EXPECT_EQ(domain.nodes().size(), 11U); + EXPECT_EQ(domain.beam_elements().size(), 10U); + EXPECT_EQ(domain.step().prescribed_dofs.size(), 6U); + ASSERT_EQ(domain.step().nodal_loads.size(), 1U); + + const auto node = std::ranges::find_if( + domain.nodes(), + [](const fesa::Node& candidate) { + return candidate.origin.local_label == 11; + }); + ASSERT_NE(node, domain.nodes().end()); + EXPECT_EQ(domain.step().nodal_loads.front().node, node->id); + EXPECT_EQ( + domain.step().nodal_loads.front().values, + (std::array{0.0, 0.0, -10000.0, 0.0, 0.0, 0.0})); +} + } // namespace