From ca3e4bc1b0c401adccd5ba6ff791b0ca8d68617e Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 1 Sep 2026 12:34:29 +0900 Subject: [PATCH] feat(shell-surface-pressure-load): step 1 - map-pressure-input-contract --- include/fesa/analysis/analysis_model.h | 4 + include/fesa/io/abaqus/input_syntax.h | 1 + include/fesa/model/domain.h | 11 + include/fesa/model/model_types.h | 80 ++- src/fesa/CMakeLists.txt | 1 + src/fesa/analysis/analysis_model.cpp | 5 + src/fesa/io/abaqus/domain_mapper.cpp | 5 + src/fesa/io/abaqus/domain_mapping_context.cpp | 494 +++++++++++++++++- src/fesa/io/abaqus/domain_mapping_context.h | 5 + src/fesa/io/abaqus/input_reader.cpp | 181 +++++-- .../io/abaqus/surface_definition_mapper.cpp | 11 + .../io/abaqus/surface_definition_mapper.h | 20 + src/fesa/model/domain.cpp | 13 + tests/CMakeLists.txt | 1 + tests/unit/io/abaqus/input_reader_test.cpp | 28 + .../shell_surface_pressure_domain_test.cpp | 304 +++++++++++ 16 files changed, 1102 insertions(+), 62 deletions(-) create mode 100644 src/fesa/io/abaqus/surface_definition_mapper.cpp create mode 100644 src/fesa/io/abaqus/surface_definition_mapper.h create mode 100644 tests/unit/io/abaqus/shell_surface_pressure_domain_test.cpp diff --git a/include/fesa/analysis/analysis_model.h b/include/fesa/analysis/analysis_model.h index 3702a40..d3fddde 100644 --- a/include/fesa/analysis/analysis_model.h +++ b/include/fesa/analysis/analysis_model.h @@ -44,6 +44,10 @@ class AnalysisModel { /// @brief Returns concentrated-load indices in source order. const std::vector& ActiveLoads() const noexcept; + /// @brief Returns active surface pressure records without copying Domain data. + const std::vector& ActiveSurfacePressureLoads() + const noexcept; + private: /// @brief Builds stable indices without copying the referenced Domain. explicit AnalysisModel(const Domain& domain); diff --git a/include/fesa/io/abaqus/input_syntax.h b/include/fesa/io/abaqus/input_syntax.h index d678eea..63db872 100644 --- a/include/fesa/io/abaqus/input_syntax.h +++ b/include/fesa/io/abaqus/input_syntax.h @@ -28,6 +28,7 @@ struct DataLine { struct KeywordBlock { std::string canonical_name; std::string original_line; + std::vector physical_lines; std::vector parameters; std::vector data; SourceLocation location; diff --git a/include/fesa/model/domain.h b/include/fesa/model/domain.h index a338f3b..21e6a0e 100644 --- a/include/fesa/model/domain.h +++ b/include/fesa/model/domain.h @@ -74,12 +74,19 @@ class StepDefinition { const DomainCollectionView& ConcentratedLoads() const noexcept; + /// @brief Returns additive surface pressure records in source order. + const std::vector& SurfacePressureLoads() + const noexcept; + /// @brief Returns the approved static procedure identity. StaticProcedureKind ProcedureKind() const noexcept; /// @brief Reports whether this step uses the nonlinear static procedure. bool IsNonlinearStatic() const noexcept; + /// @brief Reports the source NLGEOM setting for this static step. + bool UsesNonlinearGeometry() const noexcept; + /// @brief Returns the static initial increment provenance value. double InitialIncrement() const noexcept; @@ -109,6 +116,7 @@ class StepDefinition { std::vector> loads_; LoadView loads_view_; DomainCollectionView concentrated_loads_view_; + std::vector surface_pressure_loads_; StaticProcedureKind procedure_kind_; bool nlgeom_; double initial_increment_; @@ -173,6 +181,9 @@ class Domain { /// @brief Returns element sets in stable declaration order. const std::vector& ElementSets() const noexcept; + /// @brief Returns assembly element surfaces in stable declaration order. + const std::vector& ElementSurfaces() const noexcept; + /// @brief Returns static steps in stable declaration order. const DomainCollectionView& Steps() const noexcept; diff --git a/include/fesa/model/model_types.h b/include/fesa/model/model_types.h index 0f8558b..75e509a 100644 --- a/include/fesa/model/model_types.h +++ b/include/fesa/model/model_types.h @@ -53,6 +53,56 @@ struct NodalLoad { SourceLocation location; }; +/// @brief Identifies the selected exterior shell surface side. +enum class ShellSurfaceSide { kSpos, kSneg }; + +/// @brief Identifies how a surface data member resolved its element facets. +enum class SurfaceMemberKind { kDirect, kElset }; + +/// @brief Stores one resolved S4/S4R facet in an assembly element surface. +struct ElementSurfaceFacetDefinition { + EntityIndex element_index; + std::size_t facet_ordinal; + std::string part_name_raw; + std::string part_name_canonical; + std::string instance_name_raw; + std::string instance_name_canonical; + std::int64_t source_element_label; + std::string source_element_label_raw; + std::string source_element_type; + ShellSurfaceSide side; + SourceLocation first_member_location; + std::size_t first_member_ordinal; + SurfaceMemberKind member_kind; + std::string elset_name_raw; + std::string elset_name_canonical; +}; + +/// @brief Stores one immutable named assembly-level element surface. +struct ElementSurfaceDefinition { + std::string surface_name_raw; + std::string surface_name_canonical; + std::vector facets; + SourceLocation declaration_location; + std::size_t declaration_ordinal; +}; + +/// @brief Stores one ordered uniform pressure record targeting a surface. +struct SurfacePressureLoadDefinition { + std::string step_name_raw; + std::string step_name_canonical; + EntityIndex surface_index; + std::string surface_name_raw; + std::string surface_name_canonical; + std::string load_type; + double magnitude; + std::string magnitude_raw; + SourceLocation keyword_location; + SourceLocation data_location; + std::size_t data_ordinal; + std::size_t load_ordinal; +}; + /// @brief Identifies the approved static procedure semantics for one step. enum class StaticProcedureKind { kLinearSmallDisplacement, @@ -66,6 +116,7 @@ struct StaticStepDefinition { std::string step_name, std::vector step_boundaries, std::vector step_loads, + std::vector step_surface_pressure_loads, StaticProcedureKind step_procedure_kind, bool step_nlgeom, double step_initial_increment, @@ -73,10 +124,11 @@ struct StaticStepDefinition { double step_minimum_increment, double step_maximum_increment, SourceLocation step_location) - : name{std::move(step_name)}, - boundaries{std::move(step_boundaries)}, - loads{std::move(step_loads)}, - procedure_kind{step_procedure_kind}, + : name{std::move(step_name)}, + boundaries{std::move(step_boundaries)}, + loads{std::move(step_loads)}, + surface_pressure_loads{std::move(step_surface_pressure_loads)}, + procedure_kind{step_procedure_kind}, nlgeom{step_nlgeom}, initial_increment{step_initial_increment}, time_period{step_time_period}, @@ -84,6 +136,24 @@ struct StaticStepDefinition { maximum_increment{step_maximum_increment}, location{std::move(step_location)} {} + /// @brief Creates a static step without surface pressure records. + StaticStepDefinition( + std::string step_name, + std::vector step_boundaries, + std::vector step_loads, + StaticProcedureKind step_procedure_kind, + bool step_nlgeom, + double step_initial_increment, + double step_time_period, + double step_minimum_increment, + double step_maximum_increment, + SourceLocation step_location) + : StaticStepDefinition( + std::move(step_name), std::move(step_boundaries), + std::move(step_loads), {}, step_procedure_kind, step_nlgeom, + step_initial_increment, step_time_period, step_minimum_increment, + step_maximum_increment, std::move(step_location)) {} + /// @brief Creates a legacy linear static step definition. StaticStepDefinition( std::string step_name, @@ -104,6 +174,7 @@ struct StaticStepDefinition { std::string name; std::vector boundaries; std::vector loads; + std::vector surface_pressure_loads; StaticProcedureKind procedure_kind; bool nlgeom; double initial_increment; @@ -172,6 +243,7 @@ struct ModelDefinition { std::vector element_sets; std::vector parts; std::vector instances; + std::vector element_surfaces; std::vector steps; std::vector warnings; }; diff --git a/src/fesa/CMakeLists.txt b/src/fesa/CMakeLists.txt index 5de593c..c4fb200 100644 --- a/src/fesa/CMakeLists.txt +++ b/src/fesa/CMakeLists.txt @@ -33,6 +33,7 @@ add_library( io/abaqus/input_reader.cpp io/abaqus/material_property_mapper.cpp io/abaqus/step_definition_mapper.cpp + io/abaqus/surface_definition_mapper.cpp io/abaqus/topology_mapper.cpp io/hdf5/hdf5_atomic_file.cpp io/hdf5/hdf5_model_writer.cpp diff --git a/src/fesa/analysis/analysis_model.cpp b/src/fesa/analysis/analysis_model.cpp index f6a5baa..e183d9b 100644 --- a/src/fesa/analysis/analysis_model.cpp +++ b/src/fesa/analysis/analysis_model.cpp @@ -65,6 +65,11 @@ const std::vector& AnalysisModel::ActiveLoads() const noexcept { return active_loads_; } +const std::vector& +AnalysisModel::ActiveSurfacePressureLoads() const noexcept { + return Step().SurfacePressureLoads(); +} + AnalysisModel::AnalysisModel(const Domain& domain) : domain_{&domain} { std::vector reachable_materials(domain.Materials().Size(), false); std::vector reachable_properties(domain.Properties().Size(), false); diff --git a/src/fesa/io/abaqus/domain_mapper.cpp b/src/fesa/io/abaqus/domain_mapper.cpp index 9260ea4..a64fbe4 100644 --- a/src/fesa/io/abaqus/domain_mapper.cpp +++ b/src/fesa/io/abaqus/domain_mapper.cpp @@ -6,6 +6,7 @@ #include "io/abaqus/domain_mapping_context.h" #include "io/abaqus/material_property_mapper.h" #include "io/abaqus/step_definition_mapper.h" +#include "io/abaqus/surface_definition_mapper.h" #include "io/abaqus/topology_mapper.h" namespace fesa { @@ -26,6 +27,10 @@ Result AbaqusDomainMapper::Map(const ParsedInput& input) const { if (!status.IsOk()) { return Result::Failure(std::move(status)); } + status = abaqus_internal::SurfaceDefinitionMapper{}.Map(context); + if (!status.IsOk()) { + return Result::Failure(std::move(status)); + } status = abaqus_internal::StepDefinitionMapper{}.Map(context); if (!status.IsOk()) { return Result::Failure(std::move(status)); diff --git a/src/fesa/io/abaqus/domain_mapping_context.cpp b/src/fesa/io/abaqus/domain_mapping_context.cpp index 87984a3..0544ed2 100644 --- a/src/fesa/io/abaqus/domain_mapping_context.cpp +++ b/src/fesa/io/abaqus/domain_mapping_context.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -29,6 +30,15 @@ std::vector WithoutTrailingEmpty(std::vector fields) { return fields; } +std::string CanonicalizeAscii(std::string value) { + for (char& character : value) { + if (character >= 'a' && character <= 'z') { + character = static_cast(character - 'a' + 'A'); + } + } + return value; +} + struct RawNode { std::int64_t label; std::string label_text; @@ -95,6 +105,30 @@ struct RawAssemblySet { SourceLocation location; }; +struct RawSurfaceMember { + std::string target; + ShellSurfaceSide side; + SourceLocation location; + std::size_t ordinal; +}; + +struct RawElementSurface { + std::string name; + SourceLocation location; + std::size_t declaration_ordinal; + std::vector members; +}; + +struct RawSurfacePressureLoad { + std::string surface_name; + double magnitude; + std::string magnitude_raw; + SourceLocation keyword_location; + SourceLocation data_location; + std::size_t data_ordinal; + std::size_t load_ordinal; +}; + struct RawMaterial { std::string name; double youngs_modulus{0.0}; @@ -106,6 +140,8 @@ struct RawMaterial { struct RawStep { SourceLocation location; + std::string name_raw; + std::string name_canonical; bool has_static{false}; StaticProcedureKind procedure_kind{ StaticProcedureKind::kLinearSmallDisplacement}; @@ -113,6 +149,8 @@ struct RawStep { std::array static_values{}; std::vector boundaries; std::vector loads; + std::vector surface_pressure_loads; + std::size_t next_load_ordinal{0U}; }; struct MappingFailure { @@ -173,6 +211,13 @@ class DomainMappingContext::Impl { return CurrentStatus(); } + Status FinalizeElementSurfaces() { + if (!failure_) { + ResolveElementSurfaces(); + } + return CurrentStatus(); + } + Status FinalizeStepCandidate() { if (!failure_) { FinalizeStep(); @@ -346,6 +391,55 @@ class DomainMappingContext::Impl { return true; } + bool ParseSurfacePressureDouble(const std::string& text, double& value, + const SourceLocation& location, + const std::string& keyword) { + std::size_t cursor = 0U; + if (cursor < text.size() && (text[cursor] == '+' || text[cursor] == '-')) { + ++cursor; + } + const std::size_t integer_start = cursor; + while (cursor < text.size() && text[cursor] >= '0' && + text[cursor] <= '9') { + ++cursor; + } + const bool has_integer_digits = cursor != integer_start; + bool has_fraction_digits = false; + if (cursor < text.size() && text[cursor] == '.') { + ++cursor; + const std::size_t fraction_start = cursor; + while (cursor < text.size() && text[cursor] >= '0' && + text[cursor] <= '9') { + ++cursor; + } + has_fraction_digits = cursor != fraction_start; + } + if (!has_integer_digits && !has_fraction_digits) { + return InputFailure("invalid-numeric-value", location, keyword, text, + "Pressure values require C-locale decimal syntax."); + } + if (cursor < text.size() && (text[cursor] == 'E' || text[cursor] == 'e')) { + ++cursor; + if (cursor < text.size() && (text[cursor] == '+' || text[cursor] == '-')) { + ++cursor; + } + const std::size_t exponent_start = cursor; + while (cursor < text.size() && text[cursor] >= '0' && + text[cursor] <= '9') { + ++cursor; + } + if (cursor == exponent_start) { + return InputFailure("invalid-numeric-value", location, keyword, text, + "Pressure exponents require one or more digits."); + } + } + if (cursor != text.size()) { + return InputFailure("invalid-numeric-value", location, keyword, text, + "Pressure values require C-locale decimal syntax."); + } + return ParseDouble(text, value, location, keyword); + } + bool ParseNonlinearStaticDouble(const std::string& text, double& value, const SourceLocation& location, const std::string& keyword) { @@ -430,6 +524,14 @@ class DomainMappingContext::Impl { }); } + bool ContainsSurfaceName(const std::string& name) const { + return std::any_of( + element_surfaces_.begin(), element_surfaces_.end(), + [&name](const RawElementSurface& surface) { + return AsciiCaseInsensitiveEquals(surface.name, name); + }); + } + void ParseBlocks() { definition_.source_path = input_.source_path; definition_.source_content_identity = input_.source_content_identity; @@ -1159,6 +1261,13 @@ class DomainMappingContext::Impl { } assembly_set_seen_ = true; ParseAssemblySet(block, false); + } else if (block.canonical_name == "SURFACE") { + if (instances_.empty()) { + InvalidKeywordLocation( + block, "Assembly surfaces follow at least one identity instance."); + return; + } + ParseElementSurface(block); } else { RejectUnknown(block); } @@ -1217,7 +1326,11 @@ class DomainMappingContext::Impl { void ParseAssemblySet(const KeywordBlock& block, bool node_set) { const std::string_view name_parameter = node_set ? "NSET" : "ELSET"; - if (!ValidateParameters(block, {name_parameter, "INSTANCE", "GENERATE"})) { + const std::vector allowed_parameters = + node_set ? std::vector{"NSET", "INSTANCE", "GENERATE"} + : std::vector{"ELSET", "INSTANCE", "GENERATE", + "INTERNAL"}; + if (!ValidateParameters(block, allowed_parameters)) { return; } const auto* name = RequiredParameterValue(block, name_parameter); @@ -1232,6 +1345,13 @@ class DomainMappingContext::Impl { "GENERATE is a valueless flag."); return; } + const auto* internal_parameter = Parameter(block, "INTERNAL"); + if (internal_parameter != nullptr && internal_parameter->value) { + InputFailure("invalid-keyword-parameter", block.location, + block.canonical_name, "INTERNAL", + "INTERNAL is a valueless result-membership flag."); + return; + } if (ContainsAssemblySetName(node_set, *name)) { InputFailure("duplicate-entity", block.location, block.canonical_name, *name, @@ -1246,6 +1366,59 @@ class DomainMappingContext::Impl { assembly_sets_.push_back(std::move(set)); } + void ParseElementSurface(const KeywordBlock& block) { + if (!ValidateParameters(block, {"TYPE", "NAME"})) { + return; + } + const auto* type = RequiredParameterValue(block, "TYPE"); + const auto* name = RequiredParameterValue(block, "NAME"); + if (type == nullptr || name == nullptr) { + return; + } + if (!AsciiCaseInsensitiveEquals(*type, "ELEMENT")) { + InputFailure("unsupported-surface-type", block.location, + block.canonical_name, *type, + "Only TYPE=ELEMENT belongs to the approved surface subset."); + return; + } + if (ContainsSurfaceName(*name)) { + InputFailure("duplicate-entity", block.location, block.canonical_name, + *name, "Surface names are unique in the assembly namespace."); + return; + } + if (block.data.empty()) { + InputFailure("invalid-data-arity", block.location, block.canonical_name, + *name, "An element surface requires at least one member."); + return; + } + + RawElementSurface surface{*name, block.location, element_surfaces_.size(), + {}}; + for (std::size_t ordinal = 0U; ordinal < block.data.size(); ++ordinal) { + const auto& row = block.data[ordinal]; + if (row.fields.size() != 2U || row.fields[0].empty() || + row.fields[1].empty()) { + InputFailure("invalid-data-arity", row.location, block.canonical_name, + *name, + "Surface rows require one member and SPOS or SNEG."); + return; + } + ShellSurfaceSide side = ShellSurfaceSide::kSpos; + if (AsciiCaseInsensitiveEquals(row.fields[1], "SPOS")) { + side = ShellSurfaceSide::kSpos; + } else if (AsciiCaseInsensitiveEquals(row.fields[1], "SNEG")) { + side = ShellSurfaceSide::kSneg; + } else { + InputFailure("unsupported-surface-side", row.location, + block.canonical_name, row.fields[1], + "Surface sides must be SPOS or SNEG."); + return; + } + surface.members.push_back({row.fields[0], side, row.location, ordinal}); + } + element_surfaces_.push_back(std::move(surface)); + } + void ParseMaterial(const KeywordBlock& block) { if (!assembly_seen_ || model_boundary_seen_) { InvalidKeywordLocation(block, @@ -1389,6 +1562,39 @@ class DomainMappingContext::Impl { } } + void ParseDsload(const KeywordBlock& block) { + if (!ValidateParameters(block, {}) || block.data.empty()) { + if (!failure_) { + InputFailure("invalid-data-arity", block.location, block.canonical_name, + "", "DSLOAD requires one or more rows."); + } + return; + } + for (std::size_t ordinal = 0U; ordinal < block.data.size(); ++ordinal) { + const auto& row = block.data[ordinal]; + if (row.fields.size() != 3U || row.fields[0].empty() || + row.fields[1].empty() || row.fields[2].empty()) { + InputFailure("invalid-data-arity", row.location, block.canonical_name, + "", "DSLOAD rows require surface, P, magnitude."); + return; + } + if (!AsciiCaseInsensitiveEquals(row.fields[1], "P")) { + InputFailure("unsupported-load-type", row.location, + block.canonical_name, row.fields[1], + "Only uniform surface pressure load type P is supported."); + return; + } + double magnitude = 0.0; + if (!ParseSurfacePressureDouble(row.fields[2], magnitude, row.location, + block.canonical_name)) { + return; + } + step_.surface_pressure_loads.push_back( + {row.fields[0], magnitude, row.fields[2], block.location, + row.location, ordinal, step_.next_load_ordinal++}); + } + } + void ParseStepStart(const KeywordBlock& block) { if (step_seen_) { const auto* nlgeom = Parameter(block, "NLGEOM"); @@ -1444,10 +1650,21 @@ class DomainMappingContext::Impl { return; } } + const auto* name = Parameter(block, "NAME"); + if (name != nullptr && (!name->value || name->value->empty())) { + InputFailure("invalid-keyword-parameter", block.location, + block.canonical_name, "NAME", + "STEP NAME must not be empty when it is present."); + return; + } step_seen_ = true; in_step_ = true; - step_ = RawStep{block.location, false, procedure_kind, nlgeom, {}, {}, - {}}; + step_ = {}; + step_.location = block.location; + step_.name_raw = name == nullptr ? "Step-1" : *name->value; + step_.name_canonical = CanonicalizeAscii(step_.name_raw); + step_.procedure_kind = procedure_kind; + step_.nlgeom = nlgeom; step_load_seen_ = false; step_no_op_seen_ = false; } @@ -1476,6 +1693,7 @@ class DomainMappingContext::Impl { if (block.canonical_name == "STATIC") { active_output_ = false; if (!step_.boundaries.empty() || !step_.loads.empty() || + !step_.surface_pressure_loads.empty() || step_no_op_seen_) { InvalidKeywordLocation( block, "STATIC must be the first keyword in the sole step."); @@ -1501,6 +1719,19 @@ class DomainMappingContext::Impl { return; } ParseCload(block, step_.loads); + if (!failure_) { + step_.next_load_ordinal += block.data.size(); + } + step_load_seen_ = !failure_; + } else if (block.canonical_name == "DSLOAD") { + active_output_ = false; + if (!step_.has_static || step_no_op_seen_) { + InvalidKeywordLocation( + block, + "DSLOAD follows STATIC and boundaries and precedes no-op requests."); + return; + } + ParseDsload(block); step_load_seen_ = !failure_; } else if (block.canonical_name == "RESTART") { active_output_ = false; @@ -2180,6 +2411,221 @@ class DomainMappingContext::Impl { } } + bool AppendSurfaceFacet( + const RawSurfaceMember& member, const RawInstance& raw_instance, + std::size_t instance_ordinal, + EntityIndex element_index, SurfaceMemberKind member_kind, + const std::string& elset_name_raw, + std::vector>* + candidates) { + if (static_cast(element_index) >= + definition_.shell_elements.size()) { + return InputFailure( + "unsupported-element-formulation", member.location, "SURFACE", + member.target, + "Element surfaces may resolve only to S4 or S4R shell elements."); + } + const RawPart* part = FindPart(raw_instance.part_name); + if (part == nullptr) { + return InputFailure("unresolved-reference", member.location, "SURFACE", + member.target, + "The surface instance must resolve to its source part."); + } + const auto& element = definition_.shell_elements[element_index]; + const std::string source_type = + element.source_type == ShellSourceElementType::kS4 ? "S4" : "S4R"; + ElementSurfaceFacetDefinition facet{ + element_index, + 0U, + part->name, + CanonicalizeAscii(part->name), + raw_instance.name, + CanonicalizeAscii(raw_instance.name), + element.source_id.source_label, + element.source_id.source_label_text, + source_type, + member.side, + member.location, + member.ordinal, + member_kind, + elset_name_raw, + member_kind == SurfaceMemberKind::kElset + ? CanonicalizeAscii(elset_name_raw) + : ""}; + candidates->push_back({std::move(facet), instance_ordinal}); + return true; + } + + bool ResolveDirectSurfaceMember( + const RawSurfaceMember& member, + std::vector>* + candidates) { + const std::size_t dot = member.target.find('.'); + if (dot == std::string::npos || dot == 0U || + dot + 1U >= member.target.size() || + member.target.find('.', dot + 1U) != std::string::npos) { + return InputFailure( + "unresolved-reference", member.location, "SURFACE", member.target, + "A direct surface member must be instance.positive-element-label."); + } + const std::string instance_name = member.target.substr(0U, dot); + std::int64_t source_label = 0; + if (!ParseInteger(member.target.substr(dot + 1U), source_label, + member.location, "SURFACE", true)) { + return false; + } + const auto raw_instance = std::find_if( + instances_.begin(), instances_.end(), [&instance_name](const RawInstance& value) { + return AsciiCaseInsensitiveEquals(value.name, instance_name); + }); + if (raw_instance == instances_.end()) { + return InputFailure("unresolved-reference", member.location, "SURFACE", + member.target, + "A direct surface member instance must resolve."); + } + const auto element = std::find_if( + definition_.shell_elements.begin(), definition_.shell_elements.end(), + [&raw_instance, source_label](const Mitc4ShellDefinition& value) { + return AsciiCaseInsensitiveEquals(value.source_id.instance_name, + raw_instance->name) && + value.source_id.source_label == source_label; + }); + if (element == definition_.shell_elements.end()) { + return InputFailure("unresolved-reference", member.location, "SURFACE", + member.target, + "A direct surface member must resolve to an S4 or S4R."); + } + return AppendSurfaceFacet( + member, *raw_instance, + static_cast(std::distance(instances_.begin(), raw_instance)), + static_cast( + std::distance(definition_.shell_elements.begin(), element)), + SurfaceMemberKind::kDirect, "", candidates); + } + + bool ResolveElsetSurfaceMember( + const RawSurfaceMember& member, + std::vector>* + candidates) { + const auto raw_set = std::find_if( + assembly_sets_.begin(), assembly_sets_.end(), [&member](const RawAssemblySet& value) { + return !value.is_node_set && + AsciiCaseInsensitiveEquals(value.name, member.target); + }); + if (raw_set == assembly_sets_.end()) { + return InputFailure("unresolved-reference", member.location, "SURFACE", + member.target, + "A surface ELSET must be assembly-scoped and explicit-instance."); + } + const auto raw_instance = std::find_if( + instances_.begin(), instances_.end(), [&raw_set](const RawInstance& value) { + return AsciiCaseInsensitiveEquals(value.name, raw_set->instance_name); + }); + if (raw_instance == instances_.end()) { + return InputFailure("unresolved-reference", member.location, "SURFACE", + member.target, "The surface ELSET instance must resolve."); + } + const auto element_set = std::find_if( + definition_.element_sets.begin(), definition_.element_sets.end(), + [&raw_set](const ElementSet& value) { + return AsciiCaseInsensitiveEquals(value.name, raw_set->name) && + value.instance_name && + AsciiCaseInsensitiveEquals(*value.instance_name, + raw_set->instance_name); + }); + if (element_set == definition_.element_sets.end()) { + return InputFailure("unresolved-reference", member.location, "SURFACE", + member.target, + "The surface ELSET must expand in its explicit instance."); + } + for (const EntityIndex element_index : element_set->element_indices) { + if (!AppendSurfaceFacet( + member, *raw_instance, + static_cast( + std::distance(instances_.begin(), raw_instance)), + element_index, SurfaceMemberKind::kElset, member.target, + candidates)) { + return false; + } + } + return true; + } + + void ResolveElementSurfaces() { + std::vector resolved_surfaces; + resolved_surfaces.reserve(element_surfaces_.size()); + for (const auto& surface : element_surfaces_) { + std::vector> + candidates; + for (const auto& member : surface.members) { + const bool is_direct = member.target.find('.') != std::string::npos; + if (!(is_direct + ? ResolveDirectSurfaceMember(member, &candidates) + : ResolveElsetSurfaceMember(member, &candidates))) { + return; + } + } + if (candidates.empty()) { + InputFailure("empty-surface", surface.location, "SURFACE", surface.name, + "An element surface must resolve at least one facet."); + return; + } + + std::vector> + unique_facets; + for (auto& candidate : candidates) { + const auto same_element = std::find_if( + unique_facets.begin(), unique_facets.end(), + [&candidate](const auto& existing) { + return existing.first.instance_name_canonical == + candidate.first.instance_name_canonical && + existing.first.source_element_label == + candidate.first.source_element_label; + }); + if (same_element != unique_facets.end()) { + if (same_element->first.side != candidate.first.side) { + InputFailure("conflicting-surface-membership", + candidate.first.first_member_location, "SURFACE", + surface.name, + "One shell element cannot appear on both surface sides."); + return; + } + definition_.warnings.push_back( + {Severity::kWarning, + "duplicate-surface-member-normalized", + candidate.first.first_member_location, + "SURFACE", + surface.name, + "A repeated same-side surface member was normalized once."}); + continue; + } + unique_facets.push_back(std::move(candidate)); + } + std::sort(unique_facets.begin(), unique_facets.end(), + [](const auto& lhs, const auto& rhs) { + const int lhs_side = + lhs.first.side == ShellSurfaceSide::kSpos ? 0 : 1; + const int rhs_side = + rhs.first.side == ShellSurfaceSide::kSpos ? 0 : 1; + return std::tie(lhs.second, lhs.first.source_element_label, + lhs_side) < + std::tie(rhs.second, rhs.first.source_element_label, + rhs_side); + }); + + ElementSurfaceDefinition resolved{surface.name, CanonicalizeAscii(surface.name), + {}, surface.location, + surface.declaration_ordinal}; + resolved.facets.reserve(unique_facets.size()); + for (std::size_t ordinal = 0U; ordinal < unique_facets.size(); ++ordinal) { + unique_facets[ordinal].first.facet_ordinal = ordinal; + resolved.facets.push_back(std::move(unique_facets[ordinal].first)); + } + resolved_surfaces.push_back(std::move(resolved)); + } + definition_.element_surfaces = std::move(resolved_surfaces); + } + /// @brief Builds one stable node-target index from the completed candidate. SourceTargetIndex BuildSourceTargetIndex() const { std::vector entries; @@ -2257,13 +2703,44 @@ class DomainMappingContext::Impl { return; } } + std::vector surface_pressure_loads; + surface_pressure_loads.reserve(step_.surface_pressure_loads.size()); + for (const auto& load : step_.surface_pressure_loads) { + const auto surface = std::find_if( + definition_.element_surfaces.begin(), + definition_.element_surfaces.end(), [&load](const auto& candidate) { + return AsciiCaseInsensitiveEquals(candidate.surface_name_raw, + load.surface_name); + }); + if (surface == definition_.element_surfaces.end()) { + InputFailure("unresolved-reference", load.data_location, "DSLOAD", + load.surface_name, + "A DSLOAD surface must resolve after surface validation."); + return; + } + surface_pressure_loads.push_back( + {step_.name_raw, + step_.name_canonical, + static_cast( + std::distance(definition_.element_surfaces.begin(), surface)), + load.surface_name, + CanonicalizeAscii(load.surface_name), + "P", + load.magnitude, + load.magnitude_raw, + load.keyword_location, + load.data_location, + load.data_ordinal, + load.load_ordinal}); + } // Static time-control values are provenance only: V0 creates exactly // the canonical final frame 0 and performs no increment loop here. definition_.steps.push_back( - {"Step-1", std::move(boundaries), step_.loads, step_.procedure_kind, - step_.nlgeom, step_.static_values[0], step_.static_values[1], - step_.static_values[2], step_.static_values[3], step_.location}); + {"Step-1", std::move(boundaries), step_.loads, + std::move(surface_pressure_loads), step_.procedure_kind, step_.nlgeom, + step_.static_values[0], step_.static_values[1], step_.static_values[2], + step_.static_values[3], step_.location}); } const ParsedInput& input_; @@ -2273,6 +2750,7 @@ class DomainMappingContext::Impl { std::vector parts_; std::vector instances_; std::vector assembly_sets_; + std::vector element_surfaces_; std::vector materials_; std::vector model_boundaries_; RawStep step_{}; @@ -2322,6 +2800,10 @@ Status DomainMappingContext::FinalizeTopology() { return impl_->FinalizeTopology(); } +Status DomainMappingContext::FinalizeElementSurfaces() { + return impl_->FinalizeElementSurfaces(); +} + Status DomainMappingContext::FinalizeStep() { return impl_->FinalizeStepCandidate(); } diff --git a/src/fesa/io/abaqus/domain_mapping_context.h b/src/fesa/io/abaqus/domain_mapping_context.h index 2821baf..f32223f 100644 --- a/src/fesa/io/abaqus/domain_mapping_context.h +++ b/src/fesa/io/abaqus/domain_mapping_context.h @@ -12,6 +12,7 @@ namespace fesa::abaqus_internal { class DomainBuilder; class MaterialPropertyMapper; +class SurfaceDefinitionMapper; class StepDefinitionMapper; class TopologyMapper; @@ -53,6 +54,7 @@ class DomainMappingContext { friend class DomainBuilder; friend class MaterialPropertyMapper; + friend class SurfaceDefinitionMapper; friend class StepDefinitionMapper; friend class TopologyMapper; @@ -65,6 +67,9 @@ class DomainMappingContext { /// @brief Expands identity instances and stable topology candidates. Status FinalizeTopology(); + /// @brief Resolves assembly-level element surfaces after topology expansion. + Status FinalizeElementSurfaces(); + /// @brief Resolves the sole static step against finalized source targets. Status FinalizeStep(); diff --git a/src/fesa/io/abaqus/input_reader.cpp b/src/fesa/io/abaqus/input_reader.cpp index 61af931..44ae217 100644 --- a/src/fesa/io/abaqus/input_reader.cpp +++ b/src/fesa/io/abaqus/input_reader.cpp @@ -26,6 +26,10 @@ bool IsAsciiWhitespace(char value) noexcept { value == '\f' || value == '\v'; } +bool IsAllowedInputCharacter(unsigned char value) noexcept { + return value == '\t' || (value >= 0x20U && value <= 0x7eU); +} + std::string Trim(std::string_view text) { while (!text.empty() && IsAsciiWhitespace(text.front())) { text.remove_prefix(1U); @@ -61,6 +65,44 @@ std::vector SplitFields(std::string_view line) { return fields; } +bool IsKeywordContinuation(std::string_view trimmed_line) noexcept { + return !trimmed_line.empty() && trimmed_line.back() == ','; +} + +bool AppendKeywordParameters(KeywordBlock& block, + const std::vector& fields, + std::size_t first_field, + bool has_continuation) { + if (has_continuation && fields.empty()) { + return false; + } + const std::size_t field_count = + has_continuation ? fields.size() - 1U : fields.size(); + if (field_count < first_field) { + return false; + } + for (std::size_t index = first_field; index < field_count; ++index) { + const std::string& field = fields[index]; + if (field.empty()) { + return false; + } + + const std::size_t equals = field.find('='); + const std::string parameter_name = + Trim(std::string_view{field}.substr(0U, equals)); + if (parameter_name.empty()) { + return false; + } + + KeywordParameter parameter{UppercaseAscii(parameter_name), std::nullopt}; + if (equals != std::string::npos) { + parameter.value = Trim(std::string_view{field}.substr(equals + 1U)); + } + block.parameters.push_back(std::move(parameter)); + } + return true; +} + /// @brief Computes the stable identity of the exact source bytes. /// @note This runs before line-ending handling so parser provenance is not /// affected by text normalization. @@ -112,7 +154,13 @@ Result AbaqusInputReader::Read( "The Abaqus input file could not be read completely."); } - ParsedInput parsed{source_path, ContentIdentity(bytes), {}}; + struct PhysicalLine { + std::string original_line; + std::string trimmed_line; + std::size_t line_number; + }; + + std::vector physical_lines; std::size_t line_start = 0U; std::size_t line_number = 1U; while (line_start < bytes.size()) { @@ -123,60 +171,20 @@ Result AbaqusInputReader::Read( if (!original_line.empty() && original_line.back() == '\r') { original_line.pop_back(); } - - const std::string trimmed_line = Trim(original_line); - if (!trimmed_line.empty() && trimmed_line.rfind("**", 0U) != 0U) { - if (trimmed_line.front() == '*') { - const auto fields = SplitFields(trimmed_line); - const std::string keyword_text = - fields.empty() ? std::string{} - : Trim(std::string_view{fields[0]}.substr(1U)); - if (keyword_text.empty()) { - return Failure(source_path, line_number, "malformed-keyword", - trimmed_line, - "A keyword line requires a non-empty keyword name."); - } - - KeywordBlock block{UppercaseAscii(keyword_text), - original_line, - {}, - {}, - {source_path, line_number}}; - for (std::size_t index = 1U; index < fields.size(); ++index) { - const std::string& field = fields[index]; - if (field.empty()) { - return Failure(source_path, line_number, "malformed-keyword", - block.canonical_name, - "A keyword parameter name cannot be empty."); - } - - const std::size_t equals = field.find('='); - const std::string parameter_name = - Trim(std::string_view{field}.substr(0U, equals)); - if (parameter_name.empty()) { - return Failure(source_path, line_number, "malformed-keyword", - block.canonical_name, - "A keyword parameter name cannot be empty."); - } - - KeywordParameter parameter{UppercaseAscii(parameter_name), - std::nullopt}; - if (equals != std::string::npos) { - parameter.value = Trim(std::string_view{field}.substr(equals + 1U)); - } - block.parameters.push_back(std::move(parameter)); - } - parsed.blocks.push_back(std::move(block)); - } else { - if (parsed.blocks.empty()) { - return Failure(source_path, line_number, "orphan-data-line", "", - "A data line must follow a keyword line."); - } - parsed.blocks.back().data.push_back( - {SplitFields(original_line), {source_path, line_number}}); + if (original_line.size() > 256U) { + return Failure(source_path, line_number, "input-line-too-long", "", + "Each physical input line is limited to 256 ASCII bytes."); + } + for (const unsigned char character : original_line) { + if (!IsAllowedInputCharacter(character)) { + return Failure(source_path, line_number, "invalid-input-character", "", + "Input lines must use printable 7-bit ASCII or tabs."); } } + physical_lines.push_back( + {original_line, Trim(original_line), line_number}); + if (newline == std::string::npos) { break; } @@ -184,6 +192,75 @@ Result AbaqusInputReader::Read( ++line_number; } + ParsedInput parsed{source_path, ContentIdentity(bytes), {}}; + for (std::size_t line_index = 0U; line_index < physical_lines.size(); + ++line_index) { + const PhysicalLine& line = physical_lines[line_index]; + const std::string& trimmed_line = line.trimmed_line; + if (!trimmed_line.empty() && trimmed_line.rfind("**", 0U) != 0U) { + if (trimmed_line.front() == '*') { + const auto fields = SplitFields(trimmed_line); + const std::string keyword_text = + fields.empty() ? std::string{} + : Trim(std::string_view{fields[0]}.substr(1U)); + if (keyword_text.empty()) { + return Failure(source_path, line.line_number, "malformed-keyword", + trimmed_line, + "A keyword line requires a non-empty keyword name."); + } + + KeywordBlock block{UppercaseAscii(keyword_text), + line.original_line, + {line.original_line}, + {}, + {}, + {source_path, line.line_number}}; + bool continuation = IsKeywordContinuation(trimmed_line); + if (!AppendKeywordParameters(block, fields, 1U, continuation)) { + return Failure(source_path, line.line_number, "malformed-keyword", + block.canonical_name, + "A keyword parameter name cannot be empty."); + } + + while (continuation) { + ++line_index; + if (line_index >= physical_lines.size()) { + return Failure(source_path, line.line_number, "malformed-keyword", + block.canonical_name, + "A keyword continuation requires a following line."); + } + const PhysicalLine& continuation_line = physical_lines[line_index]; + if (continuation_line.trimmed_line.empty() || + continuation_line.trimmed_line.rfind("**", 0U) == 0U || + continuation_line.trimmed_line.front() == '*') { + return Failure(source_path, continuation_line.line_number, + "malformed-keyword", block.canonical_name, + "A keyword continuation cannot be interrupted."); + } + const auto continuation_fields = + SplitFields(continuation_line.trimmed_line); + continuation = + IsKeywordContinuation(continuation_line.trimmed_line); + if (!AppendKeywordParameters(block, continuation_fields, 0U, + continuation)) { + return Failure(source_path, continuation_line.line_number, + "malformed-keyword", block.canonical_name, + "A keyword parameter name cannot be empty."); + } + block.physical_lines.push_back(continuation_line.original_line); + } + parsed.blocks.push_back(std::move(block)); + } else { + if (parsed.blocks.empty()) { + return Failure(source_path, line.line_number, "orphan-data-line", "", + "A data line must follow a keyword line."); + } + parsed.blocks.back().data.push_back( + {SplitFields(line.original_line), {source_path, line.line_number}}); + } + } + } + return Result::Success(std::move(parsed)); } diff --git a/src/fesa/io/abaqus/surface_definition_mapper.cpp b/src/fesa/io/abaqus/surface_definition_mapper.cpp new file mode 100644 index 0000000..0855e11 --- /dev/null +++ b/src/fesa/io/abaqus/surface_definition_mapper.cpp @@ -0,0 +1,11 @@ +#include "io/abaqus/surface_definition_mapper.h" + +#include "io/abaqus/domain_mapping_context.h" + +namespace fesa::abaqus_internal { + +Status SurfaceDefinitionMapper::Map(DomainMappingContext& context) const { + return context.FinalizeElementSurfaces(); +} + +} // namespace fesa::abaqus_internal diff --git a/src/fesa/io/abaqus/surface_definition_mapper.h b/src/fesa/io/abaqus/surface_definition_mapper.h new file mode 100644 index 0000000..916a70b --- /dev/null +++ b/src/fesa/io/abaqus/surface_definition_mapper.h @@ -0,0 +1,20 @@ +#ifndef FESA_IO_ABAQUS_SURFACE_DEFINITION_MAPPER_H_ +#define FESA_IO_ABAQUS_SURFACE_DEFINITION_MAPPER_H_ + +#include "fesa/core/status.h" + +namespace fesa::abaqus_internal { + +class DomainMappingContext; + +/// @brief Resolves assembly-level element surfaces into Domain candidates. +class SurfaceDefinitionMapper { + public: + /// @brief Maps parsed surface members after topology expansion. + /// @return Success or the first structured surface mapping failure. + Status Map(DomainMappingContext& context) const; +}; + +} // namespace fesa::abaqus_internal + +#endif // FESA_IO_ABAQUS_SURFACE_DEFINITION_MAPPER_H_ diff --git a/src/fesa/model/domain.cpp b/src/fesa/model/domain.cpp index b9d97f1..f1c7ff9 100644 --- a/src/fesa/model/domain.cpp +++ b/src/fesa/model/domain.cpp @@ -41,6 +41,7 @@ StepDefinition::StepDefinition(StaticStepDefinition definition) concentrated_loads_view_.Add(*owned); loads_.push_back(std::move(owned)); } + surface_pressure_loads_ = std::move(definition.surface_pressure_loads); } const std::string& StepDefinition::Name() const noexcept { return name_; } @@ -62,6 +63,11 @@ StepDefinition::ConcentratedLoads() const noexcept { return concentrated_loads_view_; } +const std::vector& +StepDefinition::SurfacePressureLoads() const noexcept { + return surface_pressure_loads_; +} + StaticProcedureKind StepDefinition::ProcedureKind() const noexcept { return procedure_kind_; } @@ -70,6 +76,8 @@ bool StepDefinition::IsNonlinearStatic() const noexcept { return procedure_kind_ == StaticProcedureKind::kNonlinearFullNewton; } +bool StepDefinition::UsesNonlinearGeometry() const noexcept { return nlgeom_; } + double StepDefinition::InitialIncrement() const noexcept { return initial_increment_; } @@ -148,6 +156,11 @@ const std::vector& Domain::ElementSets() const noexcept { return definition_.element_sets; } +const std::vector& Domain::ElementSurfaces() + const noexcept { + return definition_.element_surfaces; +} + const DomainCollectionView& Domain::Steps() const noexcept { return steps_view_; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8803f82..885ffa8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -35,6 +35,7 @@ add_executable( unit/io/abaqus/domain_mapper_test.cpp unit/io/abaqus/input_reader_test.cpp unit/io/abaqus/input_syntax_test.cpp + unit/io/abaqus/shell_surface_pressure_domain_test.cpp unit/io/hdf5/hdf5_results_writer_test.cpp unit/io/hdf5/nonlinear_hdf5_results_writer_test.cpp unit/io/hdf5/hdf5_writer_components_test.cpp diff --git a/tests/unit/io/abaqus/input_reader_test.cpp b/tests/unit/io/abaqus/input_reader_test.cpp index 8b05fa1..46510a3 100644 --- a/tests/unit/io/abaqus/input_reader_test.cpp +++ b/tests/unit/io/abaqus/input_reader_test.cpp @@ -121,3 +121,31 @@ TEST(InpSyntax, ReadsLegacyCantileverWithoutMutation) { EXPECT_EQ(ReadExactBytes(input_path), bytes_before); EXPECT_EQ(std::filesystem::last_write_time(input_path), timestamp_before); } + +TEST(InpSyntax, AcceptsAsciiKeywordContinuationAndPreservesPhysicalSourceLines) { + const TemporaryInputFile input{ + "keyword-continuation", + "*SuRfAcE, TYPE=ELEMENT,\r\n" + " NAME=Pressure_Surface\r\n" + "Instance-A.0010, SPOS\r\n"}; + + const auto result = fesa::AbaqusInputReader{}.Read(input.Path()); + + ASSERT_TRUE(result.HasValue()); + ASSERT_EQ(result.Value().blocks.size(), 1U); + const auto& block = result.Value().blocks[0]; + EXPECT_EQ(block.canonical_name, "SURFACE"); + EXPECT_EQ(block.original_line, "*SuRfAcE, TYPE=ELEMENT,"); + EXPECT_EQ(block.physical_lines, + (std::vector{"*SuRfAcE, TYPE=ELEMENT,", + " NAME=Pressure_Surface"})); + ASSERT_EQ(block.parameters.size(), 2U); + EXPECT_EQ(block.parameters[0].name, "TYPE"); + ASSERT_TRUE(block.parameters[0].value.has_value()); + EXPECT_EQ(*block.parameters[0].value, "ELEMENT"); + EXPECT_EQ(block.parameters[1].name, "NAME"); + ASSERT_TRUE(block.parameters[1].value.has_value()); + EXPECT_EQ(*block.parameters[1].value, "Pressure_Surface"); + ASSERT_EQ(block.data.size(), 1U); + EXPECT_EQ(block.data[0].location.line, 3U); +} diff --git a/tests/unit/io/abaqus/shell_surface_pressure_domain_test.cpp b/tests/unit/io/abaqus/shell_surface_pressure_domain_test.cpp new file mode 100644 index 0000000..9d4ed0c --- /dev/null +++ b/tests/unit/io/abaqus/shell_surface_pressure_domain_test.cpp @@ -0,0 +1,304 @@ +#include "fesa/analysis/analysis_model.h" +#include "fesa/io/abaqus/domain_mapper.h" +#include "fesa/io/abaqus/input_reader.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class TemporaryInputFile { + public: + TemporaryInputFile(const std::string& stem, const std::string& content) + : path_{std::filesystem::temp_directory_path() / + ("fesa-shell-surface-pressure-" + stem + ".inp")} { + std::ofstream stream{path_, std::ios::binary | std::ios::trunc}; + stream.write(content.data(), static_cast(content.size())); + if (!stream) { + throw std::runtime_error{"Unable to create pressure input fixture."}; + } + } + + ~TemporaryInputFile() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + const std::filesystem::path& Path() const noexcept { return path_; } + + private: + std::filesystem::path path_; +}; + +fesa::Result MapText(const std::string& stem, + const std::string& content) { + const TemporaryInputFile input{stem, content}; + const auto parsed = fesa::AbaqusInputReader{}.Read(input.Path()); + if (!parsed.HasValue()) { + return fesa::Result::Failure(parsed.GetStatus()); + } + return fesa::AbaqusDomainMapper{}.Map(parsed.Value()); +} + +std::string ShellDeck(const std::string& assembly_content, + const std::string& dsload_rows) { + return std::string{R"inp(*Part, name=Shell-Part +*Node +1, 0., 0., 0. +2, 1., 0., 0. +3, 1., 1., 0. +4, 0., 1., 0. +5, 2., 0., 0. +6, 2., 1., 0. +*Element, type=S4 +0010, 1, 2, 3, 4 +*Element, type=S4R +0020, 2, 5, 6, 3 +*Elset, elset=S4-Set +0010 +*Elset, elset=S4R-Set +0020 +*Shell Section, elset=S4-Set, material=Steel +0.1 +*Shell Section, elset=S4R-Set, material=Steel +0.1 +*End Part +*Assembly, name=Assembly +*Instance, name=Instance-B, part=Shell-Part +*End Instance +*Instance, name=Instance-A, part=Shell-Part +*End Instance +)inp"} + + assembly_content + R"inp(*End Assembly +*Material, name=Steel +*Elastic +210000., 0.3 +*Step, name=Pressure-Step, nlgeom=NO +*Static +0.1, 1., 0.01, 1. +*Dsload +)inp" + + dsload_rows + "*End Step\n"; +} + +const fesa::ElementSurfaceDefinition& OnlySurface(const fesa::Domain& domain) { + EXPECT_EQ(domain.ElementSurfaces().size(), 1U); + return domain.ElementSurfaces().front(); +} + +} // namespace + +TEST(ShellSurfacePressureInput, + MapsDirectInstanceFacetsForS4AndS4rCaseInsensitively) { + auto result = MapText( + "direct", + ShellDeck("*SuRfAcE, NAME=Direct-Surface, TYPE=ELEMENT\n" + "instance-a.0010, spos\n" + "INSTANCE-A.0020, sneg\n", + "direct-surface, P, 1.0\n")); + + ASSERT_TRUE(result.HasValue()); + const auto& surface = OnlySurface(result.Value()); + EXPECT_EQ(surface.surface_name_raw, "Direct-Surface"); + EXPECT_EQ(surface.surface_name_canonical, "DIRECT-SURFACE"); + ASSERT_EQ(surface.facets.size(), 2U); + EXPECT_EQ(surface.facets[0].instance_name_raw, "Instance-A"); + EXPECT_EQ(surface.facets[0].source_element_label, 10); + EXPECT_EQ(surface.facets[0].source_element_label_raw, "0010"); + EXPECT_EQ(surface.facets[0].source_element_type, "S4"); + EXPECT_EQ(surface.facets[0].side, fesa::ShellSurfaceSide::kSpos); + EXPECT_EQ(surface.facets[1].source_element_type, "S4R"); + EXPECT_EQ(surface.facets[1].side, fesa::ShellSurfaceSide::kSneg); +} + +TEST(ShellSurfacePressureInput, + MapsExplicitInstanceElsetAndResultNeutralInternalParameter) { + auto result = MapText( + "elset", + ShellDeck("*ELSET, ELSET=Flat-Members, INTERNAL, INSTANCE=Instance-A\n" + "10, 20\n" + "*SURFACE, TYPE=ELEMENT, NAME=Elset-Surface\n" + "flat-members, SPOS\n", + "Elset-Surface, P, 5.0\n")); + + ASSERT_TRUE(result.HasValue()); + const auto& surface = OnlySurface(result.Value()); + ASSERT_EQ(surface.facets.size(), 2U); + for (const auto& facet : surface.facets) { + EXPECT_EQ(facet.member_kind, fesa::SurfaceMemberKind::kElset); + EXPECT_EQ(facet.elset_name_raw, "flat-members"); + EXPECT_EQ(facet.elset_name_canonical, "FLAT-MEMBERS"); + EXPECT_EQ(facet.instance_name_raw, "Instance-A"); + EXPECT_EQ(facet.side, fesa::ShellSurfaceSide::kSpos); + } +} + +TEST(ShellSurfacePressureInput, + MapsMixedDirectAndElsetMembersAcrossTwoExplicitInstancesWithStableDataMemberAndElsetOrder) { + auto result = MapText( + "mixed", + ShellDeck("*ELSET, ELSET=Flat-B, INSTANCE=instance-b\n" + "20\n" + "*ELSET, ELSET=Flat-A, INSTANCE=INSTANCE-A\n" + "10\n" + "*SURFACE, TYPE=ELEMENT, NAME=Mixed-Surface\n" + "instance-a.20, SNEG\n" + "flat-b, SPOS\n" + "INSTANCE-B.10, sneg\n" + "FLAT-A, spos\n", + "Mixed-Surface, P, 3.0\n")); + + ASSERT_TRUE(result.HasValue()); + const auto& surface = OnlySurface(result.Value()); + ASSERT_EQ(surface.facets.size(), 4U); + + EXPECT_EQ(surface.facets[0].instance_name_raw, "Instance-B"); + EXPECT_EQ(surface.facets[0].source_element_label, 10); + EXPECT_EQ(surface.facets[0].side, fesa::ShellSurfaceSide::kSneg); + EXPECT_EQ(surface.facets[0].first_member_ordinal, 2U); + EXPECT_EQ(surface.facets[1].instance_name_raw, "Instance-B"); + EXPECT_EQ(surface.facets[1].source_element_label, 20); + EXPECT_EQ(surface.facets[1].member_kind, fesa::SurfaceMemberKind::kElset); + EXPECT_EQ(surface.facets[1].first_member_ordinal, 1U); + EXPECT_EQ(surface.facets[2].instance_name_raw, "Instance-A"); + EXPECT_EQ(surface.facets[2].source_element_label, 10); + EXPECT_EQ(surface.facets[2].member_kind, fesa::SurfaceMemberKind::kElset); + EXPECT_EQ(surface.facets[2].first_member_ordinal, 3U); + EXPECT_EQ(surface.facets[3].instance_name_raw, "Instance-A"); + EXPECT_EQ(surface.facets[3].source_element_label, 20); + EXPECT_EQ(surface.facets[3].member_kind, fesa::SurfaceMemberKind::kDirect); + EXPECT_EQ(surface.facets[3].first_member_ordinal, 0U); + + for (const auto& facet : surface.facets) { + EXPECT_EQ(facet.part_name_raw, "Shell-Part"); + EXPECT_EQ(facet.part_name_canonical, "SHELL-PART"); + EXPECT_EQ(facet.instance_name_canonical, + facet.instance_name_raw == "Instance-A" ? "INSTANCE-A" + : "INSTANCE-B"); + } +} + +TEST(ShellSurfacePressureInput, + PreservesSposSnegMagnitudeTextLocationsAndSourceOrder) { + auto result = MapText( + "provenance", + ShellDeck("*SURFACE, TYPE=ELEMENT, NAME=Pressure-Surface\n" + "Instance-A.10, SPOS\n" + "Instance-A.20, SNEG\n", + "pressure-surface, p, -0.0\n" + "PRESSURE-SURFACE, P, +2.50e+03\n")); + + ASSERT_TRUE(result.HasValue()); + const auto& surface = OnlySurface(result.Value()); + ASSERT_EQ(surface.facets.size(), 2U); + EXPECT_EQ(surface.facets[0].facet_ordinal, 0U); + EXPECT_EQ(surface.facets[0].side, fesa::ShellSurfaceSide::kSpos); + EXPECT_EQ(surface.facets[1].facet_ordinal, 1U); + EXPECT_EQ(surface.facets[1].side, fesa::ShellSurfaceSide::kSneg); + + const auto& step = result.Value().Steps()[0U]; + ASSERT_EQ(step.SurfacePressureLoads().size(), 2U); + const auto& first = step.SurfacePressureLoads()[0U]; + const auto& second = step.SurfacePressureLoads()[1U]; + EXPECT_EQ(first.step_name_raw, "Pressure-Step"); + EXPECT_EQ(first.step_name_canonical, "PRESSURE-STEP"); + EXPECT_EQ(first.surface_name_raw, "pressure-surface"); + EXPECT_EQ(first.surface_name_canonical, "PRESSURE-SURFACE"); + EXPECT_EQ(first.load_type, "P"); + EXPECT_DOUBLE_EQ(first.magnitude, 0.0); + EXPECT_EQ(first.magnitude_raw, "-0.0"); + EXPECT_EQ(first.data_ordinal, 0U); + EXPECT_EQ(first.load_ordinal, 0U); + EXPECT_LT(first.keyword_location.line, first.data_location.line); + EXPECT_EQ(second.magnitude_raw, "+2.50e+03"); + EXPECT_DOUBLE_EQ(second.magnitude, 2500.0); + EXPECT_EQ(second.data_ordinal, 1U); + EXPECT_EQ(second.load_ordinal, 1U); +} + +TEST(ShellSurfacePressureDomain, + OwnsCanonicalFacetsAndStepOwnedAdditivePressureRecords) { + fesa::ModelDefinition definition{}; + definition.element_surfaces = { + {"Surface", "SURFACE", {{0U, + 0U, + "Part", + "PART", + "Instance", + "INSTANCE", + 10, + "0010", + "S4", + fesa::ShellSurfaceSide::kSpos, + {"owned.inp", 3U}, + 0U, + fesa::SurfaceMemberKind::kDirect, + "", + ""}}, + {"owned.inp", 2U}, + 0U}}; + definition.steps = {{"Step", {}, {}, + {{"Step", "STEP", 0U, "Surface", "SURFACE", "P", + 1.0, "1.0", {"owned.inp", 7U}, + {"owned.inp", 8U}, 0U, 0U}}, + fesa::StaticProcedureKind::kLinearSmallDisplacement, + false, 0.1, 1.0, 0.01, 1.0, {"owned.inp", 6U}}}; + + auto result = fesa::Domain::Create(definition); + ASSERT_TRUE(result.HasValue()); + definition.element_surfaces[0].facets[0].source_element_label = 99; + definition.steps[0].surface_pressure_loads[0].magnitude = 99.0; + + const fesa::Domain& domain = result.Value(); + static_assert(std::is_same_v< + decltype(std::declval().ElementSurfaces()), + const std::vector&>); + static_assert(std::is_same_v< + decltype(std::declval() + .SurfacePressureLoads()), + const std::vector&>); + EXPECT_EQ(domain.ElementSurfaces()[0].facets[0].source_element_label, 10); + EXPECT_DOUBLE_EQ(domain.Steps()[0U].SurfacePressureLoads()[0].magnitude, + 1.0); +} + +TEST(ShellSurfacePressureDomain, + AnalysisModelBorrowsActivePressureWithoutCopyingDomain) { + auto mapped = MapText( + "analysis-model", + ShellDeck("*SURFACE, TYPE=ELEMENT, NAME=Pressure-Surface\n" + "Instance-A.10, SPOS\n", + "Pressure-Surface, P, 1.0\n")); + ASSERT_TRUE(mapped.HasValue()); + + auto model = fesa::AnalysisModel::Create(mapped.Value()); + ASSERT_TRUE(model.HasValue()); + EXPECT_EQ(&model.Value().ActiveSurfacePressureLoads(), + &mapped.Value().Steps()[0U].SurfacePressureLoads()); + EXPECT_EQ(model.Value().ActiveSurfacePressureLoads().size(), 1U); +} + +TEST(ShellSurfacePressureInput, + AcceptsWhitespaceSignedZeroAndFiniteScientificNotation) { + auto result = MapText( + "numbers", + ShellDeck("*SURFACE, TYPE=ELEMENT, NAME=Pressure-Surface\n" + "Instance-A.10, SPOS\n", + " pressure-surface , p , +0 \n" + "Pressure-Surface, P, -1.25E-3\n")); + + ASSERT_TRUE(result.HasValue()); + const auto& loads = result.Value().Steps()[0U].SurfacePressureLoads(); + ASSERT_EQ(loads.size(), 2U); + EXPECT_EQ(loads[0].magnitude_raw, "+0"); + EXPECT_DOUBLE_EQ(loads[0].magnitude, 0.0); + EXPECT_EQ(loads[1].magnitude_raw, "-1.25E-3"); + EXPECT_DOUBLE_EQ(loads[1].magnitude, -0.00125); +}