diff --git a/include/fesa/core/ascii.h b/include/fesa/core/ascii.h new file mode 100644 index 0000000..3fcb7ea --- /dev/null +++ b/include/fesa/core/ascii.h @@ -0,0 +1,28 @@ +#ifndef FESA_CORE_ASCII_H_ +#define FESA_CORE_ASCII_H_ + +#include +#include + +#include "fesa/core/status.h" + +namespace fesa { + +/// @brief Converts one ASCII uppercase byte to lowercase. +/// @return The lowercase ASCII byte, or the input byte when it is not A-Z. +char AsciiLower(char value) noexcept; + +/// @brief Compares two byte strings with ASCII-only case folding. +/// @return True when the strings have equal length and equal ASCII-folded +/// bytes. +bool AsciiCaseInsensitiveEquals(std::string_view lhs, + std::string_view rhs) noexcept; + +/// @brief Parses a complete positive base-10 source label. +/// @return The positive label or an input failure for malformed, nonpositive, +/// or out-of-range text. +Result ParsePositiveSourceLabel(std::string_view text); + +} // namespace fesa + +#endif // FESA_CORE_ASCII_H_ diff --git a/include/fesa/model/source_target_resolver.h b/include/fesa/model/source_target_resolver.h new file mode 100644 index 0000000..197dbb1 --- /dev/null +++ b/include/fesa/model/source_target_resolver.h @@ -0,0 +1,81 @@ +#ifndef FESA_MODEL_SOURCE_TARGET_RESOLVER_H_ +#define FESA_MODEL_SOURCE_TARGET_RESOLVER_H_ + +#include +#include +#include + +#include "fesa/core/source_identity.h" +#include "fesa/core/status.h" +#include "fesa/model/model_types.h" + +namespace fesa { + +class Domain; + +/// @brief Selects the independent source node or element namespace. +enum class SourceEntityKind { kNode, kElement }; + +/// @brief Maps one direct label or named-target membership to stable identity. +/// @note An empty target_name denotes a direct source-label entry. +struct SourceTargetIndexEntry { + SourceEntityKind entity_kind; + std::string instance_name; + std::string target_name; + SourceEntityId source_id; + EntityIndex entity_index; + std::size_t declaration_order; +}; + +/// @brief Owns immutable compact source-target lookup entries. +class SourceTargetIndex { + public: + /// @brief Takes ownership of compact entries from a validated model + /// candidate. + explicit SourceTargetIndex(std::vector entries); + + /// @brief Builds compact entries from an immutable semantic Domain. + /// @return An owning index that preserves Domain declaration order. + static SourceTargetIndex FromDomain(const Domain& domain); + + /// @brief Returns owned entries without exposing mutable index state. + const std::vector& Entries() const noexcept; + + private: + std::vector entries_; +}; + +/// @brief Describes one source target lookup. +struct SourceTargetQuery { + SourceEntityKind entity_kind; + std::string instance_name; + std::string target_name_or_label; +}; + +/// @brief Preserves both external source identity and stable internal index. +struct ResolvedSourceTarget { + SourceEntityId source_id; + EntityIndex entity_index; +}; + +/// @brief Resolves source labels and named targets without owning model state. +/// @note The referenced SourceTargetIndex must outlive this resolver. +class SourceTargetResolver { + public: + /// @brief Creates a non-owning resolver over an immutable index. + /// @param index Index whose lifetime must exceed the resolver lifetime. + explicit SourceTargetResolver(const SourceTargetIndex& index) noexcept; + + /// @brief Resolves one query in stable declaration order. + /// @return Stable source targets, or a deterministic input diagnostic for an + /// invalid, missing, duplicate, or ambiguous target. + Result> Resolve( + const SourceTargetQuery& query) const; + + private: + const SourceTargetIndex* index_; +}; + +} // namespace fesa + +#endif // FESA_MODEL_SOURCE_TARGET_RESOLVER_H_ diff --git a/src/fesa/CMakeLists.txt b/src/fesa/CMakeLists.txt index 258f080..af5178d 100644 --- a/src/fesa/CMakeLists.txt +++ b/src/fesa/CMakeLists.txt @@ -10,6 +10,7 @@ add_library( assembly/sparse_assembler.cpp build_info.cpp constraints/essential_constraints.cpp + core/ascii.cpp core/diagnostic.cpp core/status.cpp elements/euler_beam_3d.cpp @@ -24,6 +25,7 @@ add_library( math/vector.cpp model/domain.cpp model/shell_geometry.cpp + model/source_target_resolver.cpp results/result_recovery.cpp solvers/linear/mkl_pardiso_solver.cpp ) diff --git a/src/fesa/assembly/load_assembler.cpp b/src/fesa/assembly/load_assembler.cpp index 6dfb0a4..240f5b8 100644 --- a/src/fesa/assembly/load_assembler.cpp +++ b/src/fesa/assembly/load_assembler.cpp @@ -1,17 +1,15 @@ #include "fesa/assembly/load_assembler.h" #include -#include #include -#include #include #include #include -#include #include #include #include "fesa/constraints/essential_constraints.h" +#include "fesa/model/source_target_resolver.h" namespace fesa { namespace { @@ -27,28 +25,6 @@ Status LoadFailure(const std::string& code, const SourceLocation& location, {{Severity::kError, code, location, keyword, identity, message}}); } -char AsciiLower(const char value) { - if (value >= 'A' && value <= 'Z') { - return static_cast(value + ('a' - 'A')); - } - return value; -} - -bool EqualName(const std::string& left, const std::string& right) { - return left.size() == right.size() && - std::equal(left.begin(), left.end(), right.begin(), - [](const char left_value, const char right_value) { - return AsciiLower(left_value) == AsciiLower(right_value); - }); -} - -bool TryPositiveInteger(const std::string& text, std::int64_t& value) { - const char* const first = text.data(); - const char* const last = first + text.size(); - const auto parsed = std::from_chars(first, last, value); - return parsed.ec == std::errc{} && parsed.ptr == last && value > 0; -} - /// @brief Checks that equation-space indices preserve stable full-DOF order. bool IsStrictlyIncreasing(const std::vector& values) { return std::adjacent_find( @@ -122,52 +98,31 @@ Status ValidateDofOrder(const DofManager& dofs, return Status::Ok(); } -Result> ResolveTarget(const Domain& domain, - const NodalLoad& load) { - std::vector matching_sets; - for (const auto& set : domain.NodeSets()) { - if (EqualName(set.name, load.target)) { - matching_sets.push_back(&set); - } - } - - std::vector matching_nodes; - std::int64_t label = 0; - if (TryPositiveInteger(load.target, label)) { - for (std::size_t index = 0U; index < domain.Nodes().size(); ++index) { - if (domain.Nodes()[index].source_id.source_label == label) { - matching_nodes.push_back(static_cast(index)); - } - } - } - - if (matching_sets.size() > 1U || matching_nodes.size() > 1U || - (!matching_sets.empty() && !matching_nodes.empty())) { +Result> ResolveTarget( + const SourceTargetResolver& resolver, const Domain& domain, + const NodalLoad& load) { + auto resolved = resolver.Resolve({SourceEntityKind::kNode, "", load.target}); + if (!resolved.HasValue()) { return Result>::Failure( LoadFailure("invalid-load-target", load.location, "CLOAD", load.target, "The load target must resolve unambiguously to one node or " "one expanded node set.")); } - if (!matching_sets.empty()) { - const auto& nodes = matching_sets.front()->node_indices; - std::vector seen(domain.Nodes().size(), 0U); - for (const EntityIndex node : nodes) { - if (node >= domain.Nodes().size() || seen[node] != 0U) { - return Result>::Failure(LoadFailure( - "invalid-load-target", load.location, "CLOAD", load.target, - "The expanded node set must contain unique in-range stable node " - "identities.")); - } - seen[node] = 1U; + std::vector nodes; + nodes.reserve(resolved.Value().size()); + std::vector seen(domain.Nodes().size(), 0U); + for (const auto& target : resolved.Value()) { + const EntityIndex node = target.entity_index; + if (node >= domain.Nodes().size() || seen[node] != 0U) { + return Result>::Failure(LoadFailure( + "invalid-load-target", load.location, "CLOAD", load.target, + "The expanded node set must contain unique in-range stable node " + "identities.")); } - return Result>::Success(nodes); + seen[node] = 1U; + nodes.push_back(node); } - if (!matching_nodes.empty()) { - return Result>::Success(std::move(matching_nodes)); - } - return Result>::Failure(LoadFailure( - "invalid-load-target", load.location, "CLOAD", load.target, - "The load target must resolve to one semantic node or node set.")); + return Result>::Success(std::move(nodes)); } Status ValidateFiniteVector(const Vector& values, @@ -285,6 +240,8 @@ Result LoadAssembler::AssembleFullNodalLoad(const AnalysisModel& model, "invalid-load-order", model.Step().location, "CLOAD", model.Step().name, "The active load view must include every sole-step load once.")); } + const SourceTargetIndex target_index = SourceTargetIndex::FromDomain(domain); + const SourceTargetResolver target_resolver{target_index}; Vector full_load{expected_full_count}; // Active load indices are required to be the original source order; this @@ -311,7 +268,7 @@ Result LoadAssembler::AssembleFullNodalLoad(const AnalysisModel& model, load.target, "A nodal load magnitude must be finite.")); } - auto target = ResolveTarget(domain, load); + auto target = ResolveTarget(target_resolver, domain, load); if (!target.HasValue()) { return Result::Failure(target.GetStatus()); } diff --git a/src/fesa/core/ascii.cpp b/src/fesa/core/ascii.cpp new file mode 100644 index 0000000..7a6a24e --- /dev/null +++ b/src/fesa/core/ascii.cpp @@ -0,0 +1,49 @@ +#include "fesa/core/ascii.h" + +#include +#include +#include + +namespace fesa { + +char AsciiLower(const char value) noexcept { + if (value >= 'A' && value <= 'Z') { + return static_cast(value + ('a' - 'A')); + } + return value; +} + +bool AsciiCaseInsensitiveEquals(const std::string_view lhs, + const std::string_view rhs) noexcept { + if (lhs.size() != rhs.size()) { + return false; + } + for (std::size_t index = 0U; index < lhs.size(); ++index) { + if (AsciiLower(lhs[index]) != AsciiLower(rhs[index])) { + return false; + } + } + return true; +} + +Result ParsePositiveSourceLabel(const std::string_view text) { + std::int64_t value = 0; + const char* const storage = text.empty() ? "" : text.data(); + const char* const first = storage; + const char* const last = storage + text.size(); + const auto parsed = std::from_chars(first, last, value, 10); + if (parsed.ec == std::errc{} && parsed.ptr == last && value > 0) { + return Result::Success(value); + } + const std::string identity{text}; + return Result::Failure(Status::Failure( + FailureCategory::kInput, + {{Severity::kError, + "invalid-source-label", + {{}, 0U}, + "SOURCE_LABEL", + identity, + "A source label must be a complete positive base-10 integer."}})); +} + +} // namespace fesa diff --git a/src/fesa/fem/dof_manager.cpp b/src/fesa/fem/dof_manager.cpp index 3c39038..ef441e6 100644 --- a/src/fesa/fem/dof_manager.cpp +++ b/src/fesa/fem/dof_manager.cpp @@ -1,56 +1,29 @@ #include "fesa/fem/dof_manager.h" #include -#include #include -#include -#include #include +#include "fesa/model/source_target_resolver.h" + namespace fesa { namespace { constexpr std::size_t kDofsPerNode = 6U; -char AsciiLower(char value) { - if (value >= 'A' && value <= 'Z') { - return static_cast(value + ('a' - 'A')); - } - return value; -} - -bool EqualName(const std::string& left, const std::string& right) { - return left.size() == right.size() && - std::equal(left.begin(), left.end(), right.begin(), - [](char left_value, char right_value) { - return AsciiLower(left_value) == AsciiLower(right_value); - }); -} - -bool TryPositiveInteger(const std::string& text, std::int64_t& value) { - const char* const first = text.data(); - const char* const last = first + text.size(); - const auto parsed = std::from_chars(first, last, value); - return parsed.ec == std::errc{} && parsed.ptr == last && value > 0; -} - std::vector ExpandBoundaryTarget( - const Domain& domain, const BoundaryCondition& boundary) { - for (const auto& set : domain.NodeSets()) { - if (EqualName(set.name, boundary.target)) { - return set.node_indices; - } + const SourceTargetResolver& resolver, const BoundaryCondition& boundary) { + auto resolved = + resolver.Resolve({SourceEntityKind::kNode, "", boundary.target}); + if (!resolved.HasValue()) { + return {}; } - - std::int64_t source_label = 0; - if (TryPositiveInteger(boundary.target, source_label)) { - for (std::size_t node = 0U; node < domain.Nodes().size(); ++node) { - if (domain.Nodes()[node].source_id.source_label == source_label) { - return {static_cast(node)}; - } - } + std::vector indices; + indices.reserve(resolved.Value().size()); + for (const auto& target : resolved.Value()) { + indices.push_back(target.entity_index); } - return {}; + return indices; } template @@ -94,12 +67,14 @@ SparsePattern BuildSparsePattern( Result DofManager::Create(const AnalysisModel& model) { const Domain& domain = model.GetDomain(); + const SourceTargetIndex target_index = SourceTargetIndex::FromDomain(domain); + const SourceTargetResolver target_resolver{target_index}; const std::size_t full_count = domain.Nodes().size() * kDofsPerNode; std::vector> prescribed_by_full_dof(full_count); for (const EntityIndex boundary_index : model.ActiveBoundaryConditions()) { const auto& boundary = model.Step().boundaries.at(boundary_index); - const auto target = ExpandBoundaryTarget(domain, boundary); + const auto target = ExpandBoundaryTarget(target_resolver, boundary); for (const EntityIndex node : target) { for (int component = boundary.first_dof; component <= boundary.last_dof; ++component) { diff --git a/src/fesa/io/abaqus/domain_mapper.cpp b/src/fesa/io/abaqus/domain_mapper.cpp index 9357958..087dcb6 100644 --- a/src/fesa/io/abaqus/domain_mapper.cpp +++ b/src/fesa/io/abaqus/domain_mapper.cpp @@ -14,26 +14,14 @@ #include #include +#include "fesa/core/ascii.h" #include "fesa/math/vector3.h" #include "fesa/model/shell_geometry.h" +#include "fesa/model/source_target_resolver.h" namespace fesa { namespace { -std::string UppercaseAscii(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](char character) { - if (character >= 'a' && character <= 'z') { - return static_cast(character - 'a' + 'A'); - } - return character; - }); - return value; -} - -bool EqualName(const std::string& left, const std::string& right) { - return UppercaseAscii(left) == UppercaseAscii(right); -} - std::vector WithoutTrailingEmpty(std::vector fields) { while (!fields.empty() && fields.back().empty()) { fields.pop_back(); @@ -254,20 +242,6 @@ class MappingContext { return true; } - bool TryPositiveInteger(const std::string& text, std::int64_t& value) const { - if (text.empty()) { - return false; - } - char* end = nullptr; - errno = 0; - const long long parsed = std::strtoll(text.c_str(), &end, 10); - if (errno == ERANGE || end != text.c_str() + text.size() || parsed <= 0) { - return false; - } - value = static_cast(parsed); - return true; - } - bool ParseDouble(const std::string& text, double& value, const SourceLocation& location, const std::string& keyword) { if (text.empty()) { @@ -311,16 +285,17 @@ class MappingContext { bool ContainsName(const std::vector& values, const std::string& name) const { - return std::any_of( - values.begin(), values.end(), - [&name](const RawPart& value) { return EqualName(value.name, name); }); + return std::any_of(values.begin(), values.end(), + [&name](const RawPart& value) { + return AsciiCaseInsensitiveEquals(value.name, name); + }); } bool ContainsName(const std::vector& values, const std::string& name) const { return std::any_of(values.begin(), values.end(), [&name](const RawInstance& value) { - return EqualName(value.name, name); + return AsciiCaseInsensitiveEquals(value.name, name); }); } @@ -328,14 +303,14 @@ class MappingContext { const std::string& name) const { return std::any_of(values.begin(), values.end(), [&name](const RawMaterial& value) { - return EqualName(value.name, name); + return AsciiCaseInsensitiveEquals(value.name, name); }); } bool ContainsSetName(const std::vector& sets, const std::string& name) const { const auto matches = [&name](const RawSet& set) { - return EqualName(set.name, name); + return AsciiCaseInsensitiveEquals(set.name, name); }; return std::any_of(sets.begin(), sets.end(), matches); } @@ -344,7 +319,7 @@ class MappingContext { return std::any_of(assembly_sets_.begin(), assembly_sets_.end(), [node_set, &name](const RawAssemblySet& set) { return set.is_node_set == node_set && - EqualName(set.name, name); + AsciiCaseInsensitiveEquals(set.name, name); }); } @@ -674,15 +649,15 @@ class MappingContext { RawElement::Type element_type{}; ElementFamily element_family{}; std::size_t expected_field_count = 0U; - if (EqualName(*type, "B33")) { + if (AsciiCaseInsensitiveEquals(*type, "B33")) { element_type = RawElement::Type::kB33; element_family = ElementFamily::kBeam; expected_field_count = 3U; - } else if (EqualName(*type, "S4")) { + } else if (AsciiCaseInsensitiveEquals(*type, "S4")) { element_type = RawElement::Type::kS4; element_family = ElementFamily::kShell; expected_field_count = 5U; - } else if (EqualName(*type, "S4R")) { + } else if (AsciiCaseInsensitiveEquals(*type, "S4R")) { element_type = RawElement::Type::kS4r; element_family = ElementFamily::kShell; expected_field_count = 5U; @@ -803,9 +778,7 @@ class MappingContext { return; } if (block.data[0].fields.size() == 2U) { - std::int64_t ignored_integration_points = 0; - if (!TryPositiveInteger(block.data[0].fields[1], - ignored_integration_points)) { + if (!ParsePositiveSourceLabel(block.data[0].fields[1]).HasValue()) { InputFailure("unsupported-shell-section-option", block.data[0].location, block.canonical_name, block.data[0].fields[1], "The optional shell integration-point field must be a " @@ -932,7 +905,7 @@ class MappingContext { if (element_set == nullptr || material == nullptr || section == nullptr) { return; } - if (!EqualName(*section, "GENERAL")) { + if (!AsciiCaseInsensitiveEquals(*section, "GENERAL")) { InputFailure("unsupported-section-formulation", block.location, block.canonical_name, *section, "Only SECTION=GENERAL belongs to the approved subset."); @@ -940,7 +913,8 @@ class MappingContext { } if (std::any_of(part.sections.begin(), part.sections.end(), [element_set](const RawSection& existing) { - return EqualName(existing.element_set_name, *element_set); + return AsciiCaseInsensitiveEquals( + existing.element_set_name, *element_set); })) { InputFailure("duplicate-entity", block.location, block.canonical_name, *element_set, @@ -1332,7 +1306,7 @@ class MappingContext { "NLGEOM requires NO in the approved subset."); return; } - if (!EqualName(*nlgeom->value, "NO")) { + if (!AsciiCaseInsensitiveEquals(*nlgeom->value, "NO")) { if (model_element_family_ == ElementFamily::kShell) { InputFailure("unsupported-nonlinear-geometry", block.location, block.canonical_name, *nlgeom->value, @@ -1510,22 +1484,24 @@ class MappingContext { const RawPart* FindPart(const std::string& name) const { const auto found = std::find_if( - parts_.begin(), parts_.end(), - [&name](const RawPart& part) { return EqualName(part.name, name); }); + parts_.begin(), parts_.end(), [&name](const RawPart& part) { + return AsciiCaseInsensitiveEquals(part.name, name); + }); return found == parts_.end() ? nullptr : &*found; } const RawInstance* FindInstance(const std::string& name) const { - const auto found = std::find_if(instances_.begin(), instances_.end(), - [&name](const RawInstance& instance) { - return EqualName(instance.name, name); - }); + const auto found = + std::find_if(instances_.begin(), instances_.end(), + [&name](const RawInstance& instance) { + return AsciiCaseInsensitiveEquals(instance.name, name); + }); return found == instances_.end() ? nullptr : &*found; } std::optional FindMaterialIndex(const std::string& name) const { for (std::size_t index = 0U; index < materials_.size(); ++index) { - if (EqualName(materials_[index].name, name)) { + if (AsciiCaseInsensitiveEquals(materials_[index].name, name)) { return static_cast(index); } } @@ -1534,9 +1510,10 @@ class MappingContext { const RawSet* FindSet(const std::vector& sets, const std::string& name) const { - const auto found = std::find_if( - sets.begin(), sets.end(), - [&name](const RawSet& set) { return EqualName(set.name, name); }); + const auto found = + std::find_if(sets.begin(), sets.end(), [&name](const RawSet& set) { + return AsciiCaseInsensitiveEquals(set.name, name); + }); return found == sets.end() ? nullptr : &*found; } @@ -2000,7 +1977,8 @@ class MappingContext { const auto definition_instance = std::find_if( definition_.instances.begin(), definition_.instances.end(), [&raw_instance](const InstanceDefinition& instance) { - return EqualName(instance.name, raw_instance->name); + return AsciiCaseInsensitiveEquals(instance.name, + raw_instance->name); }); if (definition_instance == definition_.instances.end()) { InputFailure("unresolved-reference", raw_set.location, @@ -2010,11 +1988,11 @@ class MappingContext { } if (raw_set.is_node_set) { definition_.node_sets.erase( - std::remove_if(definition_.node_sets.begin(), - definition_.node_sets.end(), - [&raw_set](const NodeSet& set) { - return EqualName(set.name, raw_set.name); - }), + std::remove_if( + definition_.node_sets.begin(), definition_.node_sets.end(), + [&raw_set](const NodeSet& set) { + return AsciiCaseInsensitiveEquals(set.name, raw_set.name); + }), definition_.node_sets.end()); NodeSet set{ raw_set.name, definition_instance->name, {}, raw_set.location}; @@ -2040,7 +2018,8 @@ class MappingContext { std::remove_if(definition_.element_sets.begin(), definition_.element_sets.end(), [&raw_set](const ElementSet& set) { - return EqualName(set.name, raw_set.name); + return AsciiCaseInsensitiveEquals(set.name, + raw_set.name); }), definition_.element_sets.end()); ElementSet set{ @@ -2066,68 +2045,57 @@ class MappingContext { } } - std::optional> ResolveNodeTarget( - const std::string& target, const SourceLocation& location, - const std::string& keyword) { - std::vector matching_sets; + /// @brief Builds one stable node-target index from the completed candidate. + SourceTargetIndex BuildSourceTargetIndex() const { + std::vector entries; + std::size_t declaration_order = 0U; + for (std::size_t node = 0U; node < definition_.nodes.size(); ++node) { + const auto& source_id = definition_.nodes[node].source_id; + entries.push_back({SourceEntityKind::kNode, source_id.instance_name, "", + source_id, static_cast(node), + declaration_order++}); + } for (const auto& set : definition_.node_sets) { - if (EqualName(set.name, target)) { - matching_sets.push_back(&set); + for (const EntityIndex node : set.node_indices) { + const auto& source_id = definition_.nodes[node].source_id; + entries.push_back({SourceEntityKind::kNode, + set.instance_name.value_or(source_id.instance_name), + set.name, source_id, node, declaration_order++}); } } + return SourceTargetIndex{std::move(entries)}; + } - std::vector matching_nodes; - std::int64_t label = 0; - if (TryPositiveInteger(target, label)) { - for (std::size_t index = 0U; index < definition_.nodes.size(); ++index) { - if (definition_.nodes[index].source_id.source_label == label) { - matching_nodes.push_back(static_cast(index)); - } - } - } - - // A token is resolved only after both approved interpretations have - // been considered; declaration order never gives a node set priority - // over an equally valid direct source-label target. - if (matching_sets.size() > 1U) { + std::optional> ResolveNodeTarget( + const SourceTargetResolver& resolver, const std::string& target, + const SourceLocation& location, const std::string& keyword) { + auto resolved = resolver.Resolve({SourceEntityKind::kNode, "", target}); + if (!resolved.HasValue()) { InputFailure( "unresolved-reference", location, keyword, target, - "The node-set target is ambiguous across identity instances."); + "The boundary or load target must resolve unambiguously to one " + "node or one node set."); return std::nullopt; } - if (matching_nodes.size() > 1U) { - InputFailure("unresolved-reference", location, keyword, target, - "The direct node-label target is ambiguous across identity " - "instances."); - return std::nullopt; + std::vector indices; + indices.reserve(resolved.Value().size()); + for (const auto& entry : resolved.Value()) { + indices.push_back(entry.entity_index); } - if (matching_sets.size() == 1U && matching_nodes.size() == 1U) { - InputFailure("unresolved-reference", location, keyword, target, - "The target is ambiguous between a node-set name and a " - "direct node label."); - return std::nullopt; - } - if (matching_sets.size() == 1U) { - return matching_sets.front()->node_indices; - } - if (matching_nodes.size() == 1U) { - return matching_nodes; - } - InputFailure( - "unresolved-reference", location, keyword, target, - "The boundary or load target must resolve to one node or node set."); - return std::nullopt; + return indices; } void FinalizeStep() { std::vector boundaries = model_boundaries_; boundaries.insert(boundaries.end(), step_.boundaries.begin(), step_.boundaries.end()); + const SourceTargetIndex target_index = BuildSourceTargetIndex(); + const SourceTargetResolver target_resolver{target_index}; std::map, double> prescribed_values; for (const auto& boundary : boundaries) { - auto target = - ResolveNodeTarget(boundary.target, boundary.location, "BOUNDARY"); + auto target = ResolveNodeTarget(target_resolver, boundary.target, + boundary.location, "BOUNDARY"); if (!target) { return; } @@ -2148,7 +2116,8 @@ class MappingContext { } } for (const auto& load : step_.loads) { - if (!ResolveNodeTarget(load.target, load.location, "CLOAD")) { + if (!ResolveNodeTarget(target_resolver, load.target, load.location, + "CLOAD")) { return; } } diff --git a/src/fesa/model/source_target_resolver.cpp b/src/fesa/model/source_target_resolver.cpp new file mode 100644 index 0000000..1be572d --- /dev/null +++ b/src/fesa/model/source_target_resolver.cpp @@ -0,0 +1,227 @@ +#include "fesa/model/source_target_resolver.h" + +#include +#include +#include +#include + +#include "fesa/core/ascii.h" +#include "fesa/model/domain.h" + +namespace fesa { +namespace { + +struct Match { + const SourceTargetIndexEntry* entry; + bool named_target; + std::size_t input_order; +}; + +bool InstanceMatches(const std::string& query_instance, + const std::string& entry_instance) { + return query_instance.empty() || + AsciiCaseInsensitiveEquals(query_instance, entry_instance); +} + +bool LooksLikeInteger(const std::string& text) { + if (text.empty()) { + return false; + } + std::size_t offset = 0U; + if (text.front() == '+' || text.front() == '-') { + offset = 1U; + } + if (offset == text.size()) { + return false; + } + return std::all_of( + text.begin() + static_cast(offset), text.end(), + [](const char value) { return value >= '0' && value <= '9'; }); +} + +Diagnostic TargetDiagnostic(const std::string& code, + const std::string& identity, + const std::string& message) { + return {Severity::kError, code, {{}, 0U}, "SOURCE_TARGET", identity, message}; +} + +Result> TargetFailure( + const std::string& code, const std::string& identity, + const std::string& message) { + return Result>::Failure(Status::Failure( + FailureCategory::kInput, {TargetDiagnostic(code, identity, message)})); +} + +bool SpansInstances(const std::vector& matches, const bool named) { + const SourceTargetIndexEntry* first = nullptr; + for (const auto& match : matches) { + if (match.named_target != named) { + continue; + } + if (first == nullptr) { + first = match.entry; + } else if (!AsciiCaseInsensitiveEquals(first->instance_name, + match.entry->instance_name)) { + return true; + } + } + return false; +} + +} // namespace + +SourceTargetIndex::SourceTargetIndex( + std::vector entries) + : entries_{std::move(entries)} {} + +SourceTargetIndex SourceTargetIndex::FromDomain(const Domain& domain) { + std::vector entries; + std::size_t declaration_order = 0U; + for (std::size_t index = 0U; index < domain.Nodes().size(); ++index) { + const auto& node = domain.Nodes()[index]; + entries.push_back({SourceEntityKind::kNode, node.source_id.instance_name, + "", node.source_id, static_cast(index), + declaration_order++}); + } + for (const auto& set : domain.NodeSets()) { + for (const EntityIndex node_index : set.node_indices) { + SourceEntityId source_id{set.instance_name.value_or(""), 0, ""}; + if (node_index < domain.Nodes().size()) { + source_id = domain.Nodes()[node_index].source_id; + } + entries.push_back({SourceEntityKind::kNode, + set.instance_name.value_or(source_id.instance_name), + set.name, std::move(source_id), node_index, + declaration_order++}); + } + } + + for (std::size_t index = 0U; index < domain.Elements().size(); ++index) { + const auto& element = domain.Elements()[index]; + entries.push_back({SourceEntityKind::kElement, + element.source_id.instance_name, "", element.source_id, + static_cast(index), declaration_order++}); + } + for (std::size_t index = 0U; index < domain.ShellElements().size(); ++index) { + const auto& element = domain.ShellElements()[index]; + entries.push_back({SourceEntityKind::kElement, + element.source_id.instance_name, "", element.source_id, + static_cast(index), declaration_order++}); + } + for (const auto& set : domain.ElementSets()) { + for (const EntityIndex element_index : set.element_indices) { + SourceEntityId source_id{set.instance_name.value_or(""), 0, ""}; + if (domain.ShellElements().empty() && + element_index < domain.Elements().size()) { + source_id = domain.Elements()[element_index].source_id; + } else if (domain.Elements().empty() && + element_index < domain.ShellElements().size()) { + source_id = domain.ShellElements()[element_index].source_id; + } + entries.push_back({SourceEntityKind::kElement, + set.instance_name.value_or(source_id.instance_name), + set.name, std::move(source_id), element_index, + declaration_order++}); + } + } + return SourceTargetIndex{std::move(entries)}; +} + +const std::vector& SourceTargetIndex::Entries() + const noexcept { + return entries_; +} + +SourceTargetResolver::SourceTargetResolver( + const SourceTargetIndex& index) noexcept + : index_{&index} {} + +Result> SourceTargetResolver::Resolve( + const SourceTargetQuery& query) const { + const auto parsed_label = + ParsePositiveSourceLabel(query.target_name_or_label); + std::vector matches; + for (std::size_t input_order = 0U; input_order < index_->Entries().size(); + ++input_order) { + const auto& entry = index_->Entries()[input_order]; + if (entry.entity_kind != query.entity_kind || + !InstanceMatches(query.instance_name, entry.instance_name)) { + continue; + } + const bool named = !entry.target_name.empty() && + AsciiCaseInsensitiveEquals(entry.target_name, + query.target_name_or_label); + const bool direct = entry.target_name.empty() && parsed_label.HasValue() && + entry.source_id.source_label == parsed_label.Value(); + if (named || direct) { + matches.push_back({&entry, named, input_order}); + } + } + + if (matches.empty()) { + if (!parsed_label.HasValue() && + LooksLikeInteger(query.target_name_or_label)) { + return TargetFailure( + "invalid-source-target", query.target_name_or_label, + "A direct source target must be a positive base-10 label."); + } + return TargetFailure( + "unresolved-source-target", query.target_name_or_label, + "The source target does not resolve in its namespace."); + } + + const bool has_named = + std::any_of(matches.begin(), matches.end(), + [](const Match& match) { return match.named_target; }); + const bool has_direct = + std::any_of(matches.begin(), matches.end(), + [](const Match& match) { return !match.named_target; }); + if ((has_named && has_direct) || SpansInstances(matches, true) || + SpansInstances(matches, false)) { + return TargetFailure( + "ambiguous-source-target", query.target_name_or_label, + "The source target has more than one valid identity interpretation."); + } + + std::stable_sort( + matches.begin(), matches.end(), [](const Match& lhs, const Match& rhs) { + if (lhs.entry->declaration_order != rhs.entry->declaration_order) { + return lhs.entry->declaration_order < rhs.entry->declaration_order; + } + return lhs.input_order < rhs.input_order; + }); + + std::vector duplicate_diagnostics; + std::vector seen_indices; + for (const auto& match : matches) { + const auto duplicate = std::find(seen_indices.begin(), seen_indices.end(), + match.entry->entity_index); + if (duplicate != seen_indices.end()) { + duplicate_diagnostics.push_back(TargetDiagnostic( + "duplicate-source-target", query.target_name_or_label, + "Duplicate resolved source target " + + match.entry->source_id.source_label_text + ".")); + } else { + seen_indices.push_back(match.entry->entity_index); + } + } + if (!duplicate_diagnostics.empty()) { + return Result>::Failure(Status::Failure( + FailureCategory::kInput, std::move(duplicate_diagnostics))); + } + if (!has_named && seen_indices.size() > 1U) { + return TargetFailure( + "ambiguous-source-target", query.target_name_or_label, + "The direct source label resolves to multiple semantic entities."); + } + + std::vector resolved; + resolved.reserve(matches.size()); + for (const auto& match : matches) { + resolved.push_back({match.entry->source_id, match.entry->entity_index}); + } + return Result>::Success( + std::move(resolved)); +} + +} // namespace fesa diff --git a/src/fesa/results/result_recovery.cpp b/src/fesa/results/result_recovery.cpp index d3401f3..6e9b67d 100644 --- a/src/fesa/results/result_recovery.cpp +++ b/src/fesa/results/result_recovery.cpp @@ -2,21 +2,19 @@ #include #include -#include #include #include -#include #include #include #include #include -#include #include #include #include "fesa/elements/euler_beam_3d.h" #include "fesa/elements/mitc4_shell.h" #include "fesa/math/vector3.h" +#include "fesa/model/source_target_resolver.h" namespace fesa { namespace { @@ -324,70 +322,29 @@ Status ValidateRecoveryInputs(const AnalysisModel& model, return Status::Ok(); } -char AsciiLower(const char value) { - if (value >= 'A' && value <= 'Z') { - return static_cast(value + ('a' - 'A')); - } - return value; -} - -bool EqualName(const std::string& left, const std::string& right) { - return left.size() == right.size() && - std::equal(left.begin(), left.end(), right.begin(), - [](const char left_value, const char right_value) { - return AsciiLower(left_value) == AsciiLower(right_value); - }); -} - -bool TryPositiveInteger(const std::string& text, std::int64_t& value) { - const char* const first = text.data(); - const char* const last = first + text.size(); - const auto parsed = std::from_chars(first, last, value); - return parsed.ec == std::errc{} && parsed.ptr == last && value > 0; -} - -Result> ResolveLoadTarget(const Domain& domain, - const NodalLoad& load) { - std::vector sets; - for (const auto& set : domain.NodeSets()) { - if (EqualName(set.name, load.target)) { - sets.push_back(&set); - } - } - std::vector nodes; - std::int64_t source_label = 0; - if (TryPositiveInteger(load.target, source_label)) { - for (std::size_t node = 0U; node < domain.Nodes().size(); ++node) { - if (domain.Nodes()[node].source_id.source_label == source_label) { - nodes.push_back(static_cast(node)); - } - } - } - if (sets.size() > 1U || nodes.size() > 1U || - (!sets.empty() && !nodes.empty())) { +Result> ResolveLoadTarget( + const SourceTargetResolver& resolver, const Domain& domain, + const NodalLoad& load) { + auto resolved = resolver.Resolve({SourceEntityKind::kNode, "", load.target}); + if (!resolved.HasValue()) { return RecoveryResultFailure>( "invalid-node-station-entity", load.location, load.target, "A station-eligibility load target must resolve unambiguously."); } - if (!sets.empty()) { - std::vector seen(domain.Nodes().size(), 0U); - for (const EntityIndex node : sets.front()->node_indices) { - if (node >= domain.Nodes().size() || seen[node] != 0U) { - return RecoveryResultFailure>( - "invalid-node-station-entity", load.location, load.target, - "A station-eligibility node set must contain unique valid nodes."); - } - seen[node] = 1U; + std::vector nodes; + nodes.reserve(resolved.Value().size()); + std::vector seen(domain.Nodes().size(), 0U); + for (const auto& target : resolved.Value()) { + const EntityIndex node = target.entity_index; + if (node >= domain.Nodes().size() || seen[node] != 0U) { + return RecoveryResultFailure>( + "invalid-node-station-entity", load.location, load.target, + "A station-eligibility node set must contain unique valid nodes."); } - return Result>::Success( - sets.front()->node_indices); + seen[node] = 1U; + nodes.push_back(node); } - if (!nodes.empty()) { - return Result>::Success(std::move(nodes)); - } - return RecoveryResultFailure>( - "invalid-node-station-entity", load.location, load.target, - "A station-eligibility load target must resolve to a node or node set."); + return Result>::Success(std::move(nodes)); } bool AccumulateVectorAndScale(std::array& total, double& scale, @@ -879,6 +836,8 @@ ResultRecovery::NormalizeSectionResultantsToNodeStations( "invalid-node-station-entity", model.Step().location, model.Step().name, "The active load view must preserve every sole-step load."); } + const SourceTargetIndex target_index = SourceTargetIndex::FromDomain(domain); + const SourceTargetResolver target_resolver{target_index}; for (std::size_t order = 0U; order < model.ActiveLoads().size(); ++order) { const EntityIndex load_index = model.ActiveLoads()[order]; if (load_index != order || load_index >= model.Step().loads.size()) { @@ -893,7 +852,7 @@ ResultRecovery::NormalizeSectionResultantsToNodeStations( "nonfinite-node-station-value", load.location, load.target, "Station eligibility requires finite concentrated loads."); } - auto targets = ResolveLoadTarget(domain, load); + auto targets = ResolveLoadTarget(target_resolver, domain, load); if (!targets.HasValue()) { return Result>::Failure( targets.GetStatus()); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 866d78d..e9df759 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,6 +9,7 @@ add_executable( unit/assembly/load_assembler_test.cpp unit/assembly/sparse_assembler_test.cpp unit/constraints/essential_constraints_test.cpp + unit/core/ascii_test.cpp unit/core/diagnostic_test.cpp unit/core/source_identity_test.cpp unit/core/status_test.cpp @@ -27,6 +28,7 @@ add_executable( unit/model/domain_test.cpp unit/model/model_types_test.cpp unit/model/shell_geometry_test.cpp + unit/model/source_target_resolver_test.cpp unit/results/result_records_test.cpp unit/results/result_recovery_test.cpp unit/results/results_writer_test.cpp diff --git a/tests/unit/core/ascii_test.cpp b/tests/unit/core/ascii_test.cpp new file mode 100644 index 0000000..c0fff58 --- /dev/null +++ b/tests/unit/core/ascii_test.cpp @@ -0,0 +1,57 @@ +#include "fesa/core/ascii.h" + +#include + +#include +#include +#include +#include + +TEST(Ascii, LowercasesOnlyAsciiUppercaseBytes) { + EXPECT_EQ(fesa::AsciiLower('A'), 'a'); + EXPECT_EQ(fesa::AsciiLower('Z'), 'z'); + EXPECT_EQ(fesa::AsciiLower('a'), 'a'); + EXPECT_EQ(fesa::AsciiLower('0'), '0'); + + const char non_ascii = static_cast(0xc0U); + EXPECT_EQ(fesa::AsciiLower(non_ascii), non_ascii); +} + +TEST(Ascii, ComparesNamesWithoutLocaleOrUnicodeFolding) { + EXPECT_TRUE(fesa::AsciiCaseInsensitiveEquals("Beam-1", "bEaM-1")); + EXPECT_FALSE(fesa::AsciiCaseInsensitiveEquals("Beam-1", "Beam-10")); + + const std::string uppercase_byte{static_cast(0xc0U)}; + const std::string lowercase_byte{static_cast(0xe0U)}; + EXPECT_FALSE( + fesa::AsciiCaseInsensitiveEquals(uppercase_byte, lowercase_byte)); +} + +TEST(Ascii, ParsesOnlyCompletePositiveBaseTenSourceLabels) { + auto one = fesa::ParsePositiveSourceLabel("1"); + auto leading_zeroes = fesa::ParsePositiveSourceLabel("00042"); + auto maximum = fesa::ParsePositiveSourceLabel( + std::to_string((std::numeric_limits::max)())); + + ASSERT_TRUE(one.HasValue()); + EXPECT_EQ(one.Value(), 1); + ASSERT_TRUE(leading_zeroes.HasValue()); + EXPECT_EQ(leading_zeroes.Value(), 42); + ASSERT_TRUE(maximum.HasValue()); + EXPECT_EQ(maximum.Value(), (std::numeric_limits::max)()); + + const std::string overflow = + std::to_string((std::numeric_limits::max)()) + "0"; + const std::vector invalid_values{"", "0", "-1", "+", "+1", + " 1", "1 ", "1x", overflow}; + for (const std::string& invalid : invalid_values) { + SCOPED_TRACE(invalid); + auto result = fesa::ParsePositiveSourceLabel(invalid); + ASSERT_FALSE(result.HasValue()); + EXPECT_EQ(result.GetStatus().Category(), fesa::FailureCategory::kInput); + ASSERT_EQ(result.GetStatus().Diagnostics().size(), 1U); + EXPECT_EQ(result.GetStatus().Diagnostics()[0U].code, + "invalid-source-label"); + EXPECT_EQ(result.GetStatus().Diagnostics()[0U].entity_identity, invalid); + } +} diff --git a/tests/unit/model/source_target_resolver_test.cpp b/tests/unit/model/source_target_resolver_test.cpp new file mode 100644 index 0000000..fc94cb8 --- /dev/null +++ b/tests/unit/model/source_target_resolver_test.cpp @@ -0,0 +1,135 @@ +#include "fesa/model/source_target_resolver.h" + +#include + +#include +#include +#include +#include +#include + +namespace { + +fesa::SourceTargetIndexEntry Entry(const fesa::SourceEntityKind entity_kind, + std::string instance_name, + std::string target_name, + const std::int64_t source_label, + std::string source_label_text, + const fesa::EntityIndex entity_index, + const std::size_t declaration_order) { + fesa::SourceEntityId source_id{instance_name, source_label, + std::move(source_label_text)}; + return {entity_kind, + std::move(instance_name), + std::move(target_name), + std::move(source_id), + entity_index, + declaration_order}; +} + +std::vector Indices( + const std::vector& targets) { + std::vector indices; + indices.reserve(targets.size()); + for (const auto& target : targets) { + indices.push_back(target.entity_index); + } + return indices; +} + +void ExpectFailure(const fesa::SourceTargetIndex& index, + fesa::SourceTargetQuery query, const std::string& code) { + const auto result = fesa::SourceTargetResolver{index}.Resolve(query); + ASSERT_FALSE(result.HasValue()); + EXPECT_EQ(result.GetStatus().Category(), fesa::FailureCategory::kInput); + ASSERT_FALSE(result.GetStatus().Diagnostics().empty()); + EXPECT_EQ(result.GetStatus().Diagnostics()[0U].code, code); + EXPECT_EQ(result.GetStatus().Diagnostics()[0U].entity_identity, + query.target_name_or_label); +} + +} // namespace + +TEST(SourceTargetResolver, + SeparatesNamespacesAndInstancesAndExpandsInDeclarationOrder) { + using fesa::SourceEntityKind; + fesa::SourceTargetIndex index{{ + Entry(SourceEntityKind::kNode, "Beam-1", "Ends", 20, "0020", 1U, 30U), + Entry(SourceEntityKind::kElement, "Beam-1", "Ends", 700, "0700", 4U, 10U), + Entry(SourceEntityKind::kNode, "Beam-1", "Ends", 10, "0010", 0U, 20U), + Entry(SourceEntityKind::kNode, "Beam-2", "Ends", 10, "0010", 2U, 40U), + Entry(SourceEntityKind::kNode, "Beam-1", "", 10, "0010", 0U, 0U), + }}; + const fesa::SourceTargetResolver resolver{index}; + + auto nodes = resolver.Resolve({SourceEntityKind::kNode, "bEaM-1", "eNdS"}); + ASSERT_TRUE(nodes.HasValue()); + EXPECT_EQ(Indices(nodes.Value()), (std::vector{0U, 1U})); + EXPECT_EQ(nodes.Value()[0U].source_id.instance_name, "Beam-1"); + EXPECT_EQ(nodes.Value()[0U].source_id.source_label_text, "0010"); + + auto elements = + resolver.Resolve({SourceEntityKind::kElement, "BEAM-1", "ends"}); + ASSERT_TRUE(elements.HasValue()); + EXPECT_EQ(Indices(elements.Value()), (std::vector{4U})); + + auto second_instance = + resolver.Resolve({SourceEntityKind::kNode, "beam-2", "ENDS"}); + ASSERT_TRUE(second_instance.HasValue()); + EXPECT_EQ(Indices(second_instance.Value()), + (std::vector{2U})); + + auto direct = resolver.Resolve({SourceEntityKind::kNode, "Beam-1", "10"}); + ASSERT_TRUE(direct.HasValue()); + EXPECT_EQ(Indices(direct.Value()), (std::vector{0U})); +} + +TEST(SourceTargetResolver, RejectsMissingAmbiguousAndNonpositiveTargets) { + using fesa::SourceEntityKind; + const fesa::SourceTargetIndex index{{ + Entry(SourceEntityKind::kNode, "Beam-1", "", 10, "10", 0U, 0U), + Entry(SourceEntityKind::kNode, "Beam-2", "", 10, "10", 1U, 1U), + Entry(SourceEntityKind::kNode, "Beam-1", "10", 20, "20", 2U, 2U), + Entry(SourceEntityKind::kNode, "Beam-1", "Shared", 10, "10", 0U, 3U), + Entry(SourceEntityKind::kNode, "Beam-2", "Shared", 10, "10", 1U, 4U), + }}; + + ExpectFailure(index, {SourceEntityKind::kNode, "", "Missing"}, + "unresolved-source-target"); + ExpectFailure(index, {SourceEntityKind::kNode, "", "0"}, + "invalid-source-target"); + ExpectFailure(index, {SourceEntityKind::kNode, "", "-7"}, + "invalid-source-target"); + ExpectFailure(index, {SourceEntityKind::kNode, "", "10"}, + "ambiguous-source-target"); + ExpectFailure(index, {SourceEntityKind::kNode, "", "Shared"}, + "ambiguous-source-target"); + ExpectFailure(index, {SourceEntityKind::kNode, "Beam-1", "10"}, + "ambiguous-source-target"); +} + +TEST(SourceTargetResolver, + ReportsDuplicateMembershipsInDeterministicDeclarationOrder) { + using fesa::SourceEntityKind; + const fesa::SourceTargetIndex index{{ + Entry(SourceEntityKind::kNode, "Beam-1", "Duplicate", 10, "10", 0U, 1U), + Entry(SourceEntityKind::kNode, "Beam-1", "Duplicate", 10, "10", 0U, 9U), + Entry(SourceEntityKind::kNode, "Beam-1", "Duplicate", 20, "20", 1U, 2U), + Entry(SourceEntityKind::kNode, "Beam-1", "Duplicate", 20, "20", 1U, 7U), + }}; + + const auto result = fesa::SourceTargetResolver{index}.Resolve( + {SourceEntityKind::kNode, "Beam-1", "duplicate"}); + + ASSERT_FALSE(result.HasValue()); + EXPECT_EQ(result.GetStatus().Category(), fesa::FailureCategory::kInput); + ASSERT_EQ(result.GetStatus().Diagnostics().size(), 2U); + EXPECT_EQ(result.GetStatus().Diagnostics()[0U].code, + "duplicate-source-target"); + EXPECT_EQ(result.GetStatus().Diagnostics()[0U].message, + "Duplicate resolved source target 20."); + EXPECT_EQ(result.GetStatus().Diagnostics()[1U].code, + "duplicate-source-target"); + EXPECT_EQ(result.GetStatus().Diagnostics()[1U].message, + "Duplicate resolved source target 10."); +}