From 9e74398655db56eb523d56fd1c9249b904cced30 Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Sun, 16 Aug 2026 10:07:02 +0900 Subject: [PATCH] feat(cpp-object-oriented-modular-refactoring): step 15 - generic-dof-manager --- include/fesa/fem/dof_manager.h | 44 ++- src/fesa/assembly/load_assembler.cpp | 90 +---- .../constraints/essential_constraints.cpp | 69 +--- src/fesa/fem/dof_manager.cpp | 368 ++++++++++++++---- tests/unit/fem/dof_manager_test.cpp | 174 +++++++++ 5 files changed, 527 insertions(+), 218 deletions(-) diff --git a/include/fesa/fem/dof_manager.h b/include/fesa/fem/dof_manager.h index 4a3dacc..07a3902 100644 --- a/include/fesa/fem/dof_manager.h +++ b/include/fesa/fem/dof_manager.h @@ -12,6 +12,8 @@ namespace fesa { +class DofManagerTestAccess; + /// @brief Stores the stable structural CSR pattern. struct SparsePattern { std::vector row_offsets; @@ -21,9 +23,22 @@ struct SparsePattern { /// @brief Owns full/free/constrained numbering, scatter maps, and CSR pattern. class DofManager { public: + /// @brief Creates an empty candidate for atomic Build replacement. + DofManager() = default; + /// @brief Creates every equation-space mapping for an active model. + /// @note This compatibility entry point derives temporary semantic layouts; + /// the procedure-owned runtime element view supersedes it in Step 20. static Result Create(const AnalysisModel& model); + /// @brief Builds mappings from runtime element layouts in supplied order. + /// @param analysis_model Non-owning active model view that outlives this + /// call. + /// @param elements Runtime elements in stable active source order. + /// @return Success after atomic replacement or a structured model failure. + Status Build(const AnalysisModel& analysis_model, + const ElementView& elements); + /// @brief Returns the full node-by-component DOF count. std::size_t FullDofCount() const noexcept; /// @brief Returns the free-equation count. @@ -34,11 +49,16 @@ class DofManager { std::size_t FullDof(EntityIndex node, DofComponent component) const; /// @brief Returns the free equation for a full DOF when unconstrained. std::optional FreeEquation(std::size_t full_dof) const; + /// @brief Maps one declared runtime layout to stable full DOFs. + /// @return The declared node/component scatter or a layout failure. + Result> ElementScatter( + const ElementDofLayout& layout) const; /// @brief Returns a beam scatter in endpoint/component order. - const std::array& ElementScatter(EntityIndex element) const; + /// @note This compatibility wrapper delegates to the generic stored layout. + std::array ElementScatter(EntityIndex element) const; /// @brief Returns a shell scatter in node/component order. - const std::array& ShellElementScatter( - EntityIndex element) const; + /// @note This compatibility wrapper delegates to the generic stored layout. + std::array ShellElementScatter(EntityIndex element) const; /// @brief Returns free full DOFs in stable increasing order. const std::vector& FreeDofs() const noexcept; /// @brief Returns constrained full DOFs in stable increasing order. @@ -47,24 +67,30 @@ class DofManager { const Vector& PrescribedValues() const noexcept; /// @brief Returns the full-space structural CSR pattern. const SparsePattern& GetSparsePattern() const noexcept; + /// @brief Validates the complete owner-issued equation and pattern mapping. + Status ValidateInvariants() const; private: + friend class DofManagerTestAccess; + + /// @brief Builds from copied layouts after the caller fixes their order. + Status BuildLayouts(const AnalysisModel& analysis_model, + const std::vector& layouts); + /// @brief Takes ownership of fully validated stable equation mappings. DofManager(std::size_t full_dof_count, std::vector> free_equations, - std::vector> element_scatters, - std::vector> shell_element_scatters, + std::vector> element_scatters, std::vector free_dofs, std::vector constrained_dofs, Vector prescribed_values, SparsePattern sparse_pattern); - std::size_t full_dof_count_; + std::size_t full_dof_count_{0U}; std::vector> free_equations_; - std::vector> element_scatters_; - std::vector> shell_element_scatters_; + std::vector> element_scatters_; std::vector free_dofs_; std::vector constrained_dofs_; - Vector prescribed_values_; + Vector prescribed_values_{0U}; SparsePattern sparse_pattern_; }; diff --git a/src/fesa/assembly/load_assembler.cpp b/src/fesa/assembly/load_assembler.cpp index a3f2358..c5123b5 100644 --- a/src/fesa/assembly/load_assembler.cpp +++ b/src/fesa/assembly/load_assembler.cpp @@ -25,79 +25,6 @@ Status LoadFailure(const std::string& code, const SourceLocation& location, {{Severity::kError, code, location, keyword, identity, message}}); } -/// @brief Checks that equation-space indices preserve stable full-DOF order. -bool IsStrictlyIncreasing(const std::vector& values) { - return std::adjacent_find( - values.begin(), values.end(), - [](const std::size_t left, const std::size_t right) { - return left >= right; - }) == values.end(); -} - -/// @brief Validates the full/free/constrained partition used by load assembly. -Status ValidateDofOrder(const DofManager& dofs, - const std::size_t expected_full_count, - const SourceLocation& location) { - const std::size_t full_count = dofs.FullDofCount(); - const auto& free_dofs = dofs.FreeDofs(); - const auto& constrained_dofs = dofs.ConstrainedDofs(); - if (full_count != expected_full_count || - free_dofs.size() != dofs.FreeDofCount() || - constrained_dofs.size() != dofs.ConstrainedDofCount() || - dofs.PrescribedValues().Size() != constrained_dofs.size() || - constrained_dofs.size() > full_count || - free_dofs.size() != full_count - constrained_dofs.size()) { - return LoadFailure("invalid-load-dimensions", location, "LOAD_ASSEMBLER", - std::to_string(full_count), - "Full, free, constrained, prescribed, and model " - "dimensions must agree."); - } - if (!IsStrictlyIncreasing(free_dofs) || - !IsStrictlyIncreasing(constrained_dofs)) { - return LoadFailure( - "invalid-load-order", location, "LOAD_ASSEMBLER", - std::to_string(full_count), - "Free and constrained DOFs must use stable increasing full-DOF order."); - } - - std::vector ownership(full_count, 0U); - try { - for (std::size_t equation = 0U; equation < free_dofs.size(); ++equation) { - const std::size_t full_dof = free_dofs[equation]; - if (full_dof >= full_count || ownership[full_dof] != 0U || - dofs.FreeEquation(full_dof) != equation) { - return LoadFailure( - "invalid-load-order", location, "LOAD_ASSEMBLER", - std::to_string(full_dof), - "Free equation numbering must match stable full-DOF order."); - } - ownership[full_dof] = 1U; - } - for (const std::size_t full_dof : constrained_dofs) { - if (full_dof >= full_count || ownership[full_dof] != 0U || - dofs.FreeEquation(full_dof).has_value()) { - return LoadFailure( - "invalid-load-order", location, "LOAD_ASSEMBLER", - std::to_string(full_dof), - "Constrained DOFs must be unique and absent from free equations."); - } - ownership[full_dof] = 2U; - } - } catch (const std::out_of_range&) { - return LoadFailure( - "invalid-load-dimensions", location, "LOAD_ASSEMBLER", - std::to_string(full_count), - "DofManager equation storage must cover every full DOF."); - } - if (std::find(ownership.begin(), ownership.end(), 0U) != ownership.end()) { - return LoadFailure( - "invalid-load-order", location, "LOAD_ASSEMBLER", - std::to_string(full_count), - "Free and constrained DOFs must partition the full range."); - } - return Status::Ok(); -} - Result> ResolveTarget( const SourceTargetResolver& resolver, const Domain& domain, const NodalLoad& load) { @@ -207,8 +134,13 @@ Result LoadAssembler::AssembleFullNodalLoad(const AnalysisModel& model, "The semantic node count cannot be represented in full-DOF order.")); } const std::size_t expected_full_count = domain.Nodes().size() * kDofsPerNode; - const Status dof_status = - ValidateDofOrder(dofs, expected_full_count, {domain.SourcePath(), 0U}); + if (dofs.FullDofCount() != expected_full_count) { + return Result::Failure(LoadFailure( + "invalid-load-dimensions", {domain.SourcePath(), 0U}, "LOAD_ASSEMBLER", + std::to_string(dofs.FullDofCount()), + "The DofManager full dimension must match the active model.")); + } + const Status dof_status = dofs.ValidateInvariants(); if (!dof_status.IsOk()) { return Result::Failure(dof_status); } @@ -296,7 +228,13 @@ Result LoadAssembler::EffectiveFreeRhs(const Vector& full_load, const Vector& prescribed_values, const DofManager& dofs) { const SourceLocation location{{}, 0U}; - const Status dof_status = ValidateDofOrder(dofs, full_load.Size(), location); + if (dofs.FullDofCount() != full_load.Size()) { + return Result::Failure( + LoadFailure("invalid-load-dimensions", location, "LOAD_ASSEMBLER", + std::to_string(dofs.FullDofCount()), + "The DofManager full dimension must match the full load.")); + } + const Status dof_status = dofs.ValidateInvariants(); if (!dof_status.IsOk()) { return Result::Failure(dof_status); } diff --git a/src/fesa/constraints/essential_constraints.cpp b/src/fesa/constraints/essential_constraints.cpp index 158a89b..d8a1c51 100644 --- a/src/fesa/constraints/essential_constraints.cpp +++ b/src/fesa/constraints/essential_constraints.cpp @@ -22,71 +22,6 @@ Status ConstraintFailure(const std::string& code, const std::string& identity, message}}); } -bool IsStrictlyIncreasing(const std::vector& values) { - return std::adjacent_find( - values.begin(), values.end(), - [](const std::size_t left, const std::size_t right) { - return left >= right; - }) == values.end(); -} - -/// @brief Validates the stable full/free/constrained numbering invariant. -Status ValidateDofOrder(const DofManager& dofs) { - const std::size_t full_count = dofs.FullDofCount(); - const auto& free_dofs = dofs.FreeDofs(); - const auto& constrained_dofs = dofs.ConstrainedDofs(); - if (free_dofs.size() != dofs.FreeDofCount() || - constrained_dofs.size() != dofs.ConstrainedDofCount() || - dofs.PrescribedValues().Size() != constrained_dofs.size() || - constrained_dofs.size() > full_count || - free_dofs.size() != full_count - constrained_dofs.size()) { - return ConstraintFailure("invalid-constraint-dimensions", - std::to_string(full_count), - "DofManager full, free, constrained, and " - "prescribed dimensions must agree."); - } - if (!IsStrictlyIncreasing(free_dofs) || - !IsStrictlyIncreasing(constrained_dofs)) { - return ConstraintFailure( - "invalid-constraint-order", std::to_string(full_count), - "Free and constrained DOFs must use stable increasing full-DOF order."); - } - - std::vector ownership(full_count, 0U); - try { - for (std::size_t equation = 0U; equation < free_dofs.size(); ++equation) { - const std::size_t full_dof = free_dofs[equation]; - if (full_dof >= full_count || ownership[full_dof] != 0U || - dofs.FreeEquation(full_dof) != equation) { - return ConstraintFailure( - "invalid-constraint-order", std::to_string(full_dof), - "Free equation numbering must match the stable free-DOF order."); - } - ownership[full_dof] = 1U; - } - for (const std::size_t full_dof : constrained_dofs) { - if (full_dof >= full_count || ownership[full_dof] != 0U || - dofs.FreeEquation(full_dof).has_value()) { - return ConstraintFailure( - "invalid-constraint-order", std::to_string(full_dof), - "Constrained DOFs must be unique and absent from free equations."); - } - ownership[full_dof] = 2U; - } - } catch (const std::out_of_range&) { - return ConstraintFailure( - "invalid-constraint-dimensions", std::to_string(full_count), - "DofManager equation storage does not cover every full DOF."); - } - if (std::find(ownership.begin(), ownership.end(), 0U) != ownership.end()) { - return ConstraintFailure("invalid-constraint-order", - std::to_string(full_count), - "Free and constrained DOFs must partition the " - "complete full-DOF range."); - } - return Status::Ok(); -} - /// @brief Extracts one partition without changing the supplied DOF order. Result ExtractBlock(const SparseMatrix& full, const std::vector& row_dofs, @@ -123,7 +58,7 @@ Result ExtractBlock(const SparseMatrix& full, } void RequireDofOrder(const DofManager& dofs) { - if (!ValidateDofOrder(dofs).IsOk()) { + if (!dofs.ValidateInvariants().IsOk()) { throw std::invalid_argument{ "DofManager constraint dimensions or order are invalid."}; } @@ -144,7 +79,7 @@ Result EssentialConstraints::Partition( "Full stiffness must be square and match the DofManager full " "dimension.")); } - const Status dof_status = ValidateDofOrder(dofs); + const Status dof_status = dofs.ValidateInvariants(); if (!dof_status.IsOk()) { return Result::Failure(dof_status); } diff --git a/src/fesa/fem/dof_manager.cpp b/src/fesa/fem/dof_manager.cpp index a00eec5..d3ec990 100644 --- a/src/fesa/fem/dof_manager.cpp +++ b/src/fesa/fem/dof_manager.cpp @@ -1,8 +1,13 @@ #include "fesa/fem/dof_manager.h" #include +#include +#include +#include #include +#include #include +#include #include "fesa/model/source_target_resolver.h" @@ -11,6 +16,37 @@ namespace { constexpr std::size_t kDofsPerNode = 6U; +Status DofFailure(const std::string& code, const SourceLocation& location, + const std::string& identity, const std::string& message) { + return Status::Failure( + FailureCategory::kModel, + {{Severity::kError, code, location, "DOF_MANAGER", identity, message}}); +} + +bool SameSourceIdentity(const SourceEntityId& left, + const SourceEntityId& right) { + return left.instance_name == right.instance_name && + left.source_label == right.source_label && + left.source_label_text == right.source_label_text; +} + +bool IsStrictlyIncreasing(const std::vector& values) { + return std::adjacent_find( + values.begin(), values.end(), + [](const std::size_t left, const std::size_t right) { + return left >= right; + }) == values.end(); +} + +bool HasAdjacentDuplicate(const std::vector& values) { + return std::adjacent_find(values.begin(), values.end()) != values.end(); +} + +std::vector FullNodeComponents() { + return {DofComponent::kUx, DofComponent::kUy, DofComponent::kUz, + DofComponent::kUrx, DofComponent::kUry, DofComponent::kUrz}; +} + std::vector ExpandBoundaryTarget( const SourceTargetResolver& resolver, const BoundaryCondition& boundary) { auto resolved = @@ -26,9 +62,8 @@ std::vector ExpandBoundaryTarget( return indices; } -template void AppendScatter(std::vector>& columns_by_row, - const std::array& scatter) { + const std::vector& scatter) { for (const std::size_t row : scatter) { auto& columns = columns_by_row[row]; columns.insert(columns.end(), scatter.begin(), scatter.end()); @@ -37,15 +72,10 @@ void AppendScatter(std::vector>& columns_by_row, /// @brief Builds sorted unique CSR columns by deterministic scatter traversal. SparsePattern BuildSparsePattern( - std::size_t full_dof_count, const std::vector& active_elements, - const std::vector>& element_scatters, - const std::vector>& shell_element_scatters) { + const std::size_t full_dof_count, + const std::vector>& element_scatters) { std::vector> columns_by_row(full_dof_count); - for (const EntityIndex element : active_elements) { - AppendScatter(columns_by_row, element_scatters.at(element)); - } - // Every shell in the approved single-step shell subset is active. - for (const auto& scatter : shell_element_scatters) { + for (const auto& scatter : element_scatters) { AppendScatter(columns_by_row, scatter); } @@ -63,17 +93,94 @@ SparsePattern BuildSparsePattern( return pattern; } +template +std::array FixedScatter( + const std::vector& scatter) { + if (scatter.size() != kSize) { + throw std::out_of_range{ + "Stored element scatter does not match the compatibility shape."}; + } + std::array fixed{}; + std::copy(scatter.begin(), scatter.end(), fixed.begin()); + return fixed; +} + } // namespace Result DofManager::Create(const AnalysisModel& model) { const Domain& domain = model.GetDomain(); + std::vector layouts; + layouts.reserve(model.ActiveElements().size()); + for (const EntityIndex element_index : model.ActiveElements()) { + if (element_index >= domain.Elements().Size()) { + return Result::Failure( + DofFailure("invalid-element-layout-order", {domain.SourcePath(), 0U}, + std::to_string(element_index), + "An active element index is outside the Domain.")); + } + const auto& definition = domain.Elements()[element_index]; + layouts.push_back({definition.SourceId(), definition.NodeIndices(), + FullNodeComponents()}); + } + + DofManager dofs; + const Status status = dofs.BuildLayouts(model, layouts); + if (!status.IsOk()) { + return Result::Failure(status); + } + return Result::Success(std::move(dofs)); +} + +Status DofManager::Build(const AnalysisModel& analysis_model, + const ElementView& elements) { + std::vector layouts; + layouts.reserve(elements.size()); + for (const auto& element : elements) { + layouts.push_back(element.get().DofLayout()); + } + return BuildLayouts(analysis_model, layouts); +} + +Status DofManager::BuildLayouts(const AnalysisModel& analysis_model, + const std::vector& layouts) { + const Domain& domain = analysis_model.GetDomain(); + if (domain.Nodes().size() > + (std::numeric_limits::max)() / kDofsPerNode) { + return DofFailure( + "invalid-dof-dimensions", {domain.SourcePath(), 0U}, + std::to_string(domain.Nodes().size()), + "The node count cannot be represented in full-DOF storage."); + } + if (layouts.size() != analysis_model.ActiveElements().size()) { + return DofFailure( + "invalid-element-layout-order", {domain.SourcePath(), 0U}, + std::to_string(layouts.size()), + "Runtime elements must match the active element inventory."); + } + + for (std::size_t source_order = 0U; source_order < layouts.size(); + ++source_order) { + const EntityIndex definition_index = + analysis_model.ActiveElements()[source_order]; + if (definition_index >= domain.Elements().Size() || + !SameSourceIdentity(layouts[source_order].source_id, + domain.Elements()[definition_index].SourceId())) { + return DofFailure( + "invalid-element-layout-order", {domain.SourcePath(), 0U}, + std::to_string(source_order), + "Runtime element layouts must preserve active source order and " + "identity."); + } + } + 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); + for (const EntityIndex boundary_index : + analysis_model.ActiveBoundaryConditions()) { + const auto& boundary = analysis_model.Step().boundaries.at(boundary_index); const auto target = ExpandBoundaryTarget(target_resolver, boundary); for (const EntityIndex node : target) { for (int component = boundary.first_dof; component <= boundary.last_dof; @@ -83,12 +190,12 @@ Result DofManager::Create(const AnalysisModel& model) { static_cast(component - 1); auto& prescribed = prescribed_by_full_dof[full_dof]; if (prescribed && *prescribed != boundary.value) { - return Result::Failure(Status::Failure( + return Status::Failure( FailureCategory::kInput, {{Severity::kError, "conflicting-boundary-condition", boundary.location, "BOUNDARY", boundary.target, "Expanded boundary rows prescribe different values to one " - "node/DOF."}})); + "node/DOF."}}); } prescribed = boundary.value; } @@ -119,44 +226,30 @@ Result DofManager::Create(const AnalysisModel& model) { prescribed_values[index] = constrained_values[index]; } - std::vector> element_scatters( - domain.BeamElements().Size()); - for (const EntityIndex element_index : model.ActiveBeamElements()) { - const auto& element = domain.BeamElements().At(element_index); - auto& scatter = element_scatters.at(element_index); - for (std::size_t endpoint = 0U; endpoint < element.node_indices.size(); - ++endpoint) { - const std::size_t node = element.node_indices[endpoint]; - for (std::size_t component = 0U; component < kDofsPerNode; ++component) { - scatter[endpoint * kDofsPerNode + component] = - node * kDofsPerNode + component; - } + DofManager candidate{full_count, + std::move(free_equations), + {}, + std::move(free_dofs), + std::move(constrained_dofs), + std::move(prescribed_values), + {}}; + candidate.element_scatters_.reserve(layouts.size()); + for (const auto& layout : layouts) { + auto scatter = candidate.ElementScatter(layout); + if (!scatter.HasValue()) { + return scatter.GetStatus(); } + candidate.element_scatters_.push_back(std::move(scatter.Value())); } + candidate.sparse_pattern_ = + BuildSparsePattern(full_count, candidate.element_scatters_); - std::vector> shell_element_scatters( - domain.ShellElements().Size()); - for (std::size_t element_index = 0U; - element_index < domain.ShellElements().Size(); ++element_index) { - const auto& element = domain.ShellElements()[element_index]; - auto& scatter = shell_element_scatters[element_index]; - for (std::size_t node_position = 0U; - node_position < element.node_indices.size(); ++node_position) { - const std::size_t node = element.node_indices[node_position]; - for (std::size_t component = 0U; component < kDofsPerNode; ++component) { - scatter[node_position * kDofsPerNode + component] = - node * kDofsPerNode + component; - } - } + const Status invariant_status = candidate.ValidateInvariants(); + if (!invariant_status.IsOk()) { + return invariant_status; } - - auto pattern = BuildSparsePattern(full_count, model.ActiveBeamElements(), - element_scatters, shell_element_scatters); - return Result::Success( - DofManager{full_count, std::move(free_equations), - std::move(element_scatters), std::move(shell_element_scatters), - std::move(free_dofs), std::move(constrained_dofs), - std::move(prescribed_values), std::move(pattern)}); + *this = std::move(candidate); + return Status::Ok(); } std::size_t DofManager::FullDofCount() const noexcept { @@ -171,8 +264,8 @@ std::size_t DofManager::ConstrainedDofCount() const noexcept { return constrained_dofs_.size(); } -std::size_t DofManager::FullDof(EntityIndex node, - DofComponent component) const { +std::size_t DofManager::FullDof(const EntityIndex node, + const DofComponent component) const { const std::size_t component_index = static_cast(component); if (node >= full_dof_count_ / kDofsPerNode || component_index >= kDofsPerNode) { @@ -182,18 +275,57 @@ std::size_t DofManager::FullDof(EntityIndex node, } std::optional DofManager::FreeEquation( - std::size_t full_dof) const { + const std::size_t full_dof) const { return free_equations_.at(full_dof); } -const std::array& DofManager::ElementScatter( - EntityIndex element) const { - return element_scatters_.at(element); +Result> DofManager::ElementScatter( + const ElementDofLayout& layout) const { + if (layout.node_indices.empty() || layout.components_per_node.empty() || + layout.node_indices.size() > (std::numeric_limits::max)() / + layout.components_per_node.size()) { + return Result>::Failure(DofFailure( + "invalid-element-dof-layout", {}, layout.source_id.source_label_text, + "An element DOF layout requires a representable nonempty topology " + "and component inventory.")); + } + + std::vector scatter; + scatter.reserve(layout.node_indices.size() * + layout.components_per_node.size()); + for (const EntityIndex node : layout.node_indices) { + for (const DofComponent component : layout.components_per_node) { + const std::size_t component_index = static_cast(component); + if (node >= full_dof_count_ / kDofsPerNode || + component_index >= kDofsPerNode) { + return Result>::Failure(DofFailure( + "invalid-element-dof-layout", {}, + layout.source_id.source_label_text, + "Element node and component identities must resolve in the full " + "DOF range.")); + } + const std::size_t full_dof = + static_cast(node) * kDofsPerNode + component_index; + if (std::find(scatter.begin(), scatter.end(), full_dof) != + scatter.end()) { + return Result>::Failure(DofFailure( + "duplicate-element-dof", {}, layout.source_id.source_label_text, + "An element layout must not repeat a full DOF.")); + } + scatter.push_back(full_dof); + } + } + return Result>::Success(std::move(scatter)); } -const std::array& DofManager::ShellElementScatter( - EntityIndex element) const { - return shell_element_scatters_.at(element); +std::array DofManager::ElementScatter( + const EntityIndex element) const { + return FixedScatter<12U>(element_scatters_.at(element)); +} + +std::array DofManager::ShellElementScatter( + const EntityIndex element) const { + return FixedScatter<24U>(element_scatters_.at(element)); } const std::vector& DofManager::FreeDofs() const noexcept { @@ -212,18 +344,122 @@ const SparsePattern& DofManager::GetSparsePattern() const noexcept { return sparse_pattern_; } -DofManager::DofManager( - std::size_t full_dof_count, - std::vector> free_equations, - std::vector> element_scatters, - std::vector> shell_element_scatters, - std::vector free_dofs, - std::vector constrained_dofs, Vector prescribed_values, - SparsePattern sparse_pattern) +Status DofManager::ValidateInvariants() const { + if (free_equations_.size() != full_dof_count_ || + free_dofs_.size() > full_dof_count_ || + constrained_dofs_.size() > full_dof_count_ || + free_dofs_.size() + constrained_dofs_.size() != full_dof_count_ || + prescribed_values_.Size() != constrained_dofs_.size()) { + return DofFailure( + "invalid-dof-dimensions", {}, std::to_string(full_dof_count_), + "Full, free, constrained, prescribed, and equation dimensions must " + "agree."); + } + if (HasAdjacentDuplicate(free_dofs_)) { + return DofFailure("duplicate-dof-mapping", {}, + std::to_string(full_dof_count_), + "Free DOF ownership must be unique."); + } + if (!IsStrictlyIncreasing(free_dofs_)) { + return DofFailure("invalid-free-dof-mapping", {}, + std::to_string(full_dof_count_), + "Free DOFs must use stable increasing full-DOF order."); + } + if (HasAdjacentDuplicate(constrained_dofs_)) { + return DofFailure("duplicate-dof-mapping", {}, + std::to_string(full_dof_count_), + "Constrained DOF ownership must be unique."); + } + if (!IsStrictlyIncreasing(constrained_dofs_)) { + return DofFailure( + "invalid-constrained-dof-mapping", {}, std::to_string(full_dof_count_), + "Constrained DOFs must use stable increasing full-DOF order."); + } + + std::vector ownership(full_dof_count_, 0U); + for (std::size_t equation = 0U; equation < free_dofs_.size(); ++equation) { + const std::size_t full_dof = free_dofs_[equation]; + if (full_dof >= full_dof_count_) { + return DofFailure("invalid-free-dof-mapping", {}, + std::to_string(full_dof), + "A free DOF is outside the full range."); + } + if (ownership[full_dof] != 0U) { + return DofFailure("duplicate-dof-mapping", {}, std::to_string(full_dof), + "Each full DOF must have one owner."); + } + if (free_equations_[full_dof] != equation) { + return DofFailure( + "invalid-equation-mapping", {}, std::to_string(full_dof), + "Free equation numbering must match stable free-DOF order."); + } + ownership[full_dof] = 1U; + } + for (const std::size_t full_dof : constrained_dofs_) { + if (full_dof >= full_dof_count_) { + return DofFailure("invalid-constrained-dof-mapping", {}, + std::to_string(full_dof), + "A constrained DOF is outside the full range."); + } + if (ownership[full_dof] != 0U) { + return DofFailure("duplicate-dof-mapping", {}, std::to_string(full_dof), + "Each full DOF must have one owner."); + } + if (free_equations_[full_dof].has_value()) { + return DofFailure("invalid-equation-mapping", {}, + std::to_string(full_dof), + "Constrained DOFs must be absent from free equations."); + } + ownership[full_dof] = 2U; + } + if (std::find(ownership.begin(), ownership.end(), 0U) != ownership.end()) { + return DofFailure( + "invalid-dof-partition", {}, std::to_string(full_dof_count_), + "Free and constrained DOFs must partition the complete full range."); + } + + for (const auto& scatter : element_scatters_) { + if (scatter.empty()) { + return DofFailure("invalid-element-dof-layout", {}, {}, + "Stored element scatters must be nonempty."); + } + for (std::size_t position = 0U; position < scatter.size(); ++position) { + const std::size_t full_dof = scatter[position]; + if (full_dof >= full_dof_count_) { + return DofFailure("invalid-element-dof-layout", {}, + std::to_string(full_dof), + "Stored element scatters must stay in range."); + } + if (std::find(scatter.begin(), + scatter.begin() + static_cast(position), + full_dof) != + scatter.begin() + static_cast(position)) { + return DofFailure("duplicate-element-dof", {}, std::to_string(full_dof), + "Stored element scatters must remain unique."); + } + } + } + + const SparsePattern expected_pattern = + BuildSparsePattern(full_dof_count_, element_scatters_); + if (sparse_pattern_.row_offsets != expected_pattern.row_offsets || + sparse_pattern_.column_indices != expected_pattern.column_indices) { + return DofFailure( + "invalid-dof-sparse-pattern", {}, std::to_string(full_dof_count_), + "The CSR pattern must exactly match the stored element scatters."); + } + return Status::Ok(); +} + +DofManager::DofManager(const std::size_t full_dof_count, + std::vector> free_equations, + std::vector> element_scatters, + std::vector free_dofs, + std::vector constrained_dofs, + Vector prescribed_values, SparsePattern sparse_pattern) : full_dof_count_{full_dof_count}, free_equations_{std::move(free_equations)}, element_scatters_{std::move(element_scatters)}, - shell_element_scatters_{std::move(shell_element_scatters)}, free_dofs_{std::move(free_dofs)}, constrained_dofs_{std::move(constrained_dofs)}, prescribed_values_{std::move(prescribed_values)}, diff --git a/tests/unit/fem/dof_manager_test.cpp b/tests/unit/fem/dof_manager_test.cpp index af54af2..c4c0624 100644 --- a/tests/unit/fem/dof_manager_test.cpp +++ b/tests/unit/fem/dof_manager_test.cpp @@ -4,13 +4,93 @@ #include #include +#include #include +#include #include +#include #include #include +namespace fesa { + +class DofManagerTestAccess { + public: + static void DuplicateOwnership(DofManager& dofs) { + dofs.constrained_dofs_[3U] = dofs.free_dofs_[7U]; + } + + static void RemoveFullMapping(DofManager& dofs) { + dofs.free_equations_.pop_back(); + } + + static void ReverseFreeMapping(DofManager& dofs) { + std::swap(dofs.free_dofs_[0U], dofs.free_dofs_[1U]); + } + + static void ReverseConstrainedMapping(DofManager& dofs) { + std::swap(dofs.constrained_dofs_[0U], dofs.constrained_dofs_[1U]); + } + + static void CorruptEquationMapping(DofManager& dofs) { + dofs.free_equations_[dofs.free_dofs_[0U]] = dofs.free_dofs_.size(); + } + + static void RemoveSparsePatternEntry(DofManager& dofs) { + const std::size_t position = dofs.sparse_pattern_.row_offsets[1U] - 1U; + dofs.sparse_pattern_.column_indices.erase( + dofs.sparse_pattern_.column_indices.begin() + + static_cast(position)); + for (std::size_t row = 1U; row < dofs.sparse_pattern_.row_offsets.size(); + ++row) { + --dofs.sparse_pattern_.row_offsets[row]; + } + } + + static void AddSparsePatternEntry(DofManager& dofs) { + const std::size_t position = dofs.sparse_pattern_.row_offsets[1U]; + dofs.sparse_pattern_.column_indices.insert( + dofs.sparse_pattern_.column_indices.begin() + + static_cast(position), + 12U); + for (std::size_t row = 1U; row < dofs.sparse_pattern_.row_offsets.size(); + ++row) { + ++dofs.sparse_pattern_.row_offsets[row]; + } + } +}; + +} // namespace fesa + namespace { +class GenericLayoutElement final : public fesa::Element { + public: + explicit GenericLayoutElement(fesa::ElementDofLayout layout) + : layout_{std::move(layout)} {} + + const fesa::ElementDofLayout& DofLayout() const noexcept override { + return layout_; + } + + fesa::Result ComputeStiffness() + const override { + const std::size_t local_dof_count = + layout_.node_indices.size() * layout_.components_per_node.size(); + return fesa::Result::Success( + {layout_, fesa::Matrix{local_dof_count, local_dof_count}}); + } + + fesa::Result Recover( + const fesa::Vector&) const override { + return fesa::Result::Success( + {layout_.source_id, fesa::BeamElementResultRows{}}); + } + + private: + fesa::ElementDofLayout layout_; +}; + fesa::ModelDefinition MakeDefinition() { const std::filesystem::path source{"models/dof-manager.inp"}; fesa::ModelDefinition definition{}; @@ -83,8 +163,102 @@ std::vector RowColumns(const fesa::SparsePattern& pattern, pattern.column_indices.begin() + pattern.row_offsets[row + 1U]}; } +void ExpectInvariantFailure(const fesa::DofManager& dofs, + const std::string& code) { + const fesa::Status status = dofs.ValidateInvariants(); + ASSERT_FALSE(status.IsOk()); + EXPECT_EQ(status.Category(), fesa::FailureCategory::kModel); + ASSERT_EQ(status.Diagnostics().size(), 1U); + EXPECT_EQ(status.Diagnostics()[0U].code, code); +} + } // namespace +// C-DOF-001 +TEST(DofManager, BuildsGenericRuntimeLayoutsInDeclaredSourceOrder) { + auto domain = fesa::Domain::Create(MakeDefinition()); + ASSERT_TRUE(domain.HasValue()); + auto model = fesa::AnalysisModel::Create(domain.Value()); + ASSERT_TRUE(model.HasValue()); + + GenericLayoutElement first{fesa::ElementDofLayout{ + {"Beam-1", 100, "100"}, + {2U, 0U}, + {fesa::DofComponent::kUz, fesa::DofComponent::kUx}}}; + GenericLayoutElement second{ + fesa::ElementDofLayout{{"Beam-1", 200, "200"}, + {1U}, + {fesa::DofComponent::kUrz, fesa::DofComponent::kUy, + fesa::DofComponent::kUrx}}}; + const fesa::ElementView elements{std::cref(first), std::cref(second)}; + + fesa::DofManager dofs; + ASSERT_TRUE(dofs.Build(model.Value(), elements).IsOk()); + ASSERT_TRUE(dofs.ValidateInvariants().IsOk()); + + auto first_scatter = dofs.ElementScatter(first.DofLayout()); + auto second_scatter = dofs.ElementScatter(second.DofLayout()); + ASSERT_TRUE(first_scatter.HasValue()); + ASSERT_TRUE(second_scatter.HasValue()); + EXPECT_EQ(first_scatter.Value(), + (std::vector{14U, 12U, 2U, 0U})); + EXPECT_EQ(second_scatter.Value(), (std::vector{11U, 7U, 9U})); + + const auto& pattern = dofs.GetSparsePattern(); + EXPECT_EQ(RowColumns(pattern, 14U), + (std::vector{0U, 2U, 12U, 14U})); + EXPECT_EQ(RowColumns(pattern, 7U), (std::vector{7U, 9U, 11U})); + + const auto expected_free_dofs = dofs.FreeDofs(); + const auto expected_constrained_dofs = dofs.ConstrainedDofs(); + const auto expected_row_offsets = pattern.row_offsets; + const auto expected_columns = pattern.column_indices; + const fesa::ElementView reversed_elements{std::cref(second), + std::cref(first)}; + const fesa::Status reversed_status = + dofs.Build(model.Value(), reversed_elements); + ASSERT_FALSE(reversed_status.IsOk()); + ASSERT_EQ(reversed_status.Diagnostics().size(), 1U); + EXPECT_EQ(reversed_status.Diagnostics()[0U].code, + "invalid-element-layout-order"); + EXPECT_EQ(dofs.FreeDofs(), expected_free_dofs); + EXPECT_EQ(dofs.ConstrainedDofs(), expected_constrained_dofs); + EXPECT_EQ(dofs.GetSparsePattern().row_offsets, expected_row_offsets); + EXPECT_EQ(dofs.GetSparsePattern().column_indices, expected_columns); +} + +TEST(DofManager, RejectsEveryCorruptedOwnerMappingInvariant) { + const auto fixture = MakeDofFixture(); + + auto duplicate = fixture.dofs; + fesa::DofManagerTestAccess::DuplicateOwnership(duplicate); + ExpectInvariantFailure(duplicate, "duplicate-dof-mapping"); + + auto full = fixture.dofs; + fesa::DofManagerTestAccess::RemoveFullMapping(full); + ExpectInvariantFailure(full, "invalid-dof-dimensions"); + + auto free = fixture.dofs; + fesa::DofManagerTestAccess::ReverseFreeMapping(free); + ExpectInvariantFailure(free, "invalid-free-dof-mapping"); + + auto constrained = fixture.dofs; + fesa::DofManagerTestAccess::ReverseConstrainedMapping(constrained); + ExpectInvariantFailure(constrained, "invalid-constrained-dof-mapping"); + + auto equation = fixture.dofs; + fesa::DofManagerTestAccess::CorruptEquationMapping(equation); + ExpectInvariantFailure(equation, "invalid-equation-mapping"); + + auto missing_pattern_entry = fixture.dofs; + fesa::DofManagerTestAccess::RemoveSparsePatternEntry(missing_pattern_entry); + ExpectInvariantFailure(missing_pattern_entry, "invalid-dof-sparse-pattern"); + + auto extra_pattern_entry = fixture.dofs; + fesa::DofManagerTestAccess::AddSparsePatternEntry(extra_pattern_entry); + ExpectInvariantFailure(extra_pattern_entry, "invalid-dof-sparse-pattern"); +} + TEST(DofManager, NumbersSixDofsAndFreeEquationsStably) { const auto fixture = MakeDofFixture(); const auto& dofs = fixture.dofs;