From e71f3ad9ac0f0cd08cb4fb48c6e309ba38217405 Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 18:59:04 +0900 Subject: [PATCH 1/2] feat: add thermal dof mapping --- .../fesa/elements/thermal_element_layout.h | 22 ++ include/fesa/fem/dof_manager.h | 21 ++ src/fesa/fem/dof_manager.cpp | 198 ++++++++++++++++++ tests/unit/fem/dof_manager_test.cpp | 72 +++++++ 4 files changed, 313 insertions(+) create mode 100644 include/fesa/elements/thermal_element_layout.h diff --git a/include/fesa/elements/thermal_element_layout.h b/include/fesa/elements/thermal_element_layout.h new file mode 100644 index 0000000..d92b03e --- /dev/null +++ b/include/fesa/elements/thermal_element_layout.h @@ -0,0 +1,22 @@ +#ifndef FESA_ELEMENTS_THERMAL_ELEMENT_LAYOUT_H_ +#define FESA_ELEMENTS_THERMAL_ELEMENT_LAYOUT_H_ + +#include + +#include "fesa/model/model_types.h" + +namespace fesa { + +/// @brief Declares the fixed node-major DS4 thermal scatter identity. +struct ThermalElementDofLayout { + SourceEntityId source_id; + std::array node_indices; + std::array components{ + {ThermalDofComponent::kBottom, ThermalDofComponent::kLower, + ThermalDofComponent::kMiddle, ThermalDofComponent::kUpper, + ThermalDofComponent::kTop}}; +}; + +} // namespace fesa + +#endif // FESA_ELEMENTS_THERMAL_ELEMENT_LAYOUT_H_ diff --git a/include/fesa/fem/dof_manager.h b/include/fesa/fem/dof_manager.h index 6fc6ebf..a25ecc6 100644 --- a/include/fesa/fem/dof_manager.h +++ b/include/fesa/fem/dof_manager.h @@ -9,12 +9,15 @@ #include "fesa/analysis/analysis_model.h" #include "fesa/constraints/boundary_condition.h" #include "fesa/elements/element.h" +#include "fesa/elements/thermal_element_layout.h" #include "fesa/math/vector.h" namespace fesa { class DofManagerTestAccess; +enum class DofProfile { kStructural, kThermal }; + /// @brief Stores the stable structural CSR pattern. struct SparsePattern { std::vector row_offsets; @@ -32,6 +35,9 @@ class DofManager { /// the procedure-owned runtime element view supersedes it in Step 20. static Result Create(const AnalysisModel& model); + /// @brief Builds the active DS4 five-station equation mapping. + static Result CreateThermal(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. @@ -46,6 +52,13 @@ class DofManager { Status Build(const AnalysisModel& analysis_model, const ElementView& elements, const BoundaryConditionView& boundaries); + /// @brief Builds thermal mappings from stable DS4 semantic layouts. + Status BuildThermal(const AnalysisModel& analysis_model, + const std::vector& layouts); + + /// @brief Reports which physical component profile owns this mapping. + DofProfile Profile() const noexcept; + /// @brief Returns the full node-by-component DOF count. std::size_t FullDofCount() const noexcept; /// @brief Returns the free-equation count. @@ -54,12 +67,18 @@ class DofManager { std::size_t ConstrainedDofCount() const noexcept; /// @brief Maps a stable node index and component to a full DOF. std::size_t FullDof(EntityIndex node, DofComponent component) const; + /// @brief Maps an active thermal Domain node and station to a full DOF. + std::size_t FullThermalDof(EntityIndex node, + ThermalDofComponent 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 Maps one DS4 layout to its node-major 20-entry scatter. + Result> ThermalElementScatter( + const ThermalElementDofLayout& layout) const; /// @brief Returns a beam scatter in endpoint/component order. /// @note This compatibility wrapper delegates to the generic stored layout. std::array ElementScatter(EntityIndex element) const; @@ -100,6 +119,8 @@ class DofManager { std::vector constrained_dofs_; Vector prescribed_values_{0U}; SparsePattern sparse_pattern_; + DofProfile profile_{DofProfile::kStructural}; + std::vector> thermal_node_rows_; }; } // namespace fesa diff --git a/src/fesa/fem/dof_manager.cpp b/src/fesa/fem/dof_manager.cpp index 55f928a..cd178bd 100644 --- a/src/fesa/fem/dof_manager.cpp +++ b/src/fesa/fem/dof_manager.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,7 @@ namespace fesa { namespace { constexpr std::size_t kDofsPerNode = 6U; +constexpr std::size_t kThermalDofsPerNode = 5U; Status DofFailure(const std::string& code, const SourceLocation& location, const std::string& identity, const std::string& message) { @@ -48,6 +50,12 @@ std::vector FullNodeComponents() { DofComponent::kUrx, DofComponent::kUry, DofComponent::kUrz}; } +bool IsThermalSurfaceDof(const int dof) { return dof == 11 || dof == 15; } + +std::size_t ThermalComponentIndex(const ThermalDofComponent component) { + return static_cast(static_cast(component) - 11); +} + void AppendScatter(std::vector>& columns_by_row, const std::vector& scatter) { for (const std::size_t row : scatter) { @@ -94,6 +102,7 @@ std::array FixedScatter( } // namespace Result DofManager::Create(const AnalysisModel& model) { + if (model.IsThermal()) return CreateThermal(model); const Domain& domain = model.GetDomain(); std::vector layouts; layouts.reserve(model.ActiveElements().size()); @@ -118,8 +127,39 @@ Result DofManager::Create(const AnalysisModel& model) { return Result::Success(std::move(dofs)); } +Result DofManager::CreateThermal(const AnalysisModel& model) { + if (!model.IsThermal()) { + return Result::Failure(DofFailure( + "invalid-dof-profile", {model.GetDomain().SourcePath(), 0U}, "thermal", + "Thermal mapping requires a thermal AnalysisModel.")); + } + const Domain& domain = model.GetDomain(); + std::vector layouts; + layouts.reserve(model.ActiveThermalElements().size()); + for (std::size_t index = 0U; index < model.ActiveThermalElements().size(); + ++index) { + if (index >= domain.ThermalElements().Size()) { + return Result::Failure(DofFailure( + "invalid-element-layout-order", {domain.SourcePath(), 0U}, + std::to_string(index), "Thermal element inventory is invalid.")); + } + const auto& element = domain.ThermalElements()[index]; + layouts.push_back({element.SourceId(), + {element.NodeIndices()[0U], element.NodeIndices()[1U], + element.NodeIndices()[2U], element.NodeIndices()[3U]}}); + } + DofManager dofs; + const Status status = dofs.BuildThermal(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) { + if (analysis_model.IsThermal()) { + return DofFailure("invalid-dof-profile", {analysis_model.GetDomain().SourcePath(), 0U}, + "structural", "Structural element layouts cannot build a thermal mapping."); + } return Build(analysis_model, elements, analysis_model.Step().BoundaryConditions()); } @@ -128,6 +168,10 @@ Status DofManager::Build(const AnalysisModel& analysis_model, const ElementView& elements, const BoundaryConditionView& boundaries) { std::vector layouts; + if (analysis_model.IsThermal()) { + return DofFailure("invalid-dof-profile", {analysis_model.GetDomain().SourcePath(), 0U}, + "structural", "Structural element layouts cannot build a thermal mapping."); + } layouts.reserve(elements.size()); for (const auto& element : elements) { layouts.push_back(element.get().DofLayout()); @@ -135,6 +179,100 @@ Status DofManager::Build(const AnalysisModel& analysis_model, return BuildLayouts(analysis_model, layouts, boundaries); } +Status DofManager::BuildThermal( + const AnalysisModel& analysis_model, + const std::vector& layouts) { + if (!analysis_model.IsThermal() || analysis_model.ThermalStep() == nullptr) { + return DofFailure("invalid-dof-profile", {analysis_model.GetDomain().SourcePath(), 0U}, + "thermal", "Thermal layouts require a thermal AnalysisModel."); + } + const Domain& domain = analysis_model.GetDomain(); + if (layouts.size() != analysis_model.ActiveThermalElements().size()) { + return DofFailure("invalid-element-layout-order", {domain.SourcePath(), 0U}, + std::to_string(layouts.size()), "Thermal layouts must match the active DS4 inventory."); + } + DofManager candidate; + candidate.profile_ = DofProfile::kThermal; + candidate.thermal_node_rows_.resize(domain.Nodes().size()); + for (std::size_t row = 0U; row < analysis_model.ActiveThermalNodes().size(); + ++row) { + const EntityIndex node = analysis_model.ActiveThermalNodes()[row]; + if (node >= candidate.thermal_node_rows_.size()) { + return DofFailure("invalid-thermal-node", {domain.SourcePath(), 0U}, + std::to_string(node), "Active thermal node is outside Domain nodes."); + } + candidate.thermal_node_rows_[node] = row; + } + candidate.full_dof_count_ = + analysis_model.ActiveThermalNodes().size() * kThermalDofsPerNode; + candidate.element_scatters_.reserve(layouts.size()); + for (std::size_t index = 0U; index < layouts.size(); ++index) { + const auto& definition = domain.ThermalElements()[index]; + if (!SameSourceIdentity(layouts[index].source_id, definition.SourceId())) { + return DofFailure("invalid-element-layout-order", {domain.SourcePath(), 0U}, + std::to_string(index), "Thermal layout identity changed source order."); + } + for (std::size_t node = 0U; node < layouts[index].node_indices.size(); + ++node) { + if (layouts[index].node_indices[node] != definition.NodeIndices()[node]) { + return DofFailure("invalid-element-layout-order", + {domain.SourcePath(), 0U}, std::to_string(index), + "Thermal layout nodes must preserve DS4 order."); + } + } + const auto scatter = candidate.ThermalElementScatter(layouts[index]); + if (!scatter.HasValue()) return scatter.GetStatus(); + candidate.element_scatters_.push_back(scatter.Value()); + } + std::map effective; + const auto apply = [&candidate, &effective]( + const std::vector& boundaries, + const bool overrides) -> Status { + for (const auto& boundary : boundaries) { + if (!IsThermalSurfaceDof(boundary.dof) || !std::isfinite(boundary.value)) { + return DofFailure("invalid-thermal-boundary", boundary.location, "BOUNDARY", + "Thermal boundaries require finite DOF 11 or 15 values."); + } + std::size_t full_dof = 0U; + try { + full_dof = candidate.FullThermalDof( + boundary.node_index, static_cast(boundary.dof)); + } catch (const std::out_of_range&) { + return DofFailure("invalid-thermal-boundary", boundary.location, "BOUNDARY", + "Thermal boundary target is inactive."); + } + if (overrides || effective.find(full_dof) == effective.end()) { + effective[full_dof] = boundary.value; + } + } + return Status::Ok(); + }; + Status status = apply(domain.ThermalModelBoundaries(), false); + if (!status.IsOk()) return status; + status = apply(analysis_model.ThermalStep()->Boundaries(), true); + if (!status.IsOk()) return status; + candidate.free_equations_.resize(candidate.full_dof_count_); + for (std::size_t full = 0U; full < candidate.full_dof_count_; ++full) { + const auto prescribed = effective.find(full); + if (prescribed != effective.end()) { + candidate.constrained_dofs_.push_back(full); + } else { + candidate.free_equations_[full] = candidate.free_dofs_.size(); + candidate.free_dofs_.push_back(full); + } + } + candidate.prescribed_values_ = Vector{candidate.constrained_dofs_.size()}; + for (std::size_t index = 0U; index < candidate.constrained_dofs_.size(); ++index) { + candidate.prescribed_values_[index] = effective[candidate.constrained_dofs_[index]]; + } + candidate.sparse_pattern_ = + BuildSparsePattern(candidate.full_dof_count_, candidate.element_scatters_); + status = candidate.ValidateInvariants(); + if (!status.IsOk()) return status; + *this = std::move(candidate); + return Status::Ok(); +} + Status DofManager::BuildLayouts(const AnalysisModel& analysis_model, const std::vector& layouts, const BoundaryConditionView& boundaries) { @@ -292,6 +430,8 @@ std::size_t DofManager::FullDofCount() const noexcept { return full_dof_count_; } +DofProfile DofManager::Profile() const noexcept { return profile_; } + std::size_t DofManager::FreeDofCount() const noexcept { return free_dofs_.size(); } @@ -302,6 +442,9 @@ std::size_t DofManager::ConstrainedDofCount() const noexcept { std::size_t DofManager::FullDof(const EntityIndex node, const DofComponent component) const { + if (profile_ != DofProfile::kStructural) { + throw std::logic_error{"Structural components are invalid for thermal DOFs."}; + } const std::size_t component_index = static_cast(component); if (node >= full_dof_count_ / kDofsPerNode || component_index >= kDofsPerNode) { @@ -310,6 +453,17 @@ std::size_t DofManager::FullDof(const EntityIndex node, return static_cast(node) * kDofsPerNode + component_index; } +std::size_t DofManager::FullThermalDof( + const EntityIndex node, const ThermalDofComponent component) const { + if (profile_ != DofProfile::kThermal || node >= thermal_node_rows_.size() || + !thermal_node_rows_[node].has_value() || + ThermalComponentIndex(component) >= kThermalDofsPerNode) { + throw std::out_of_range{"Thermal node or component is out of range."}; + } + return *thermal_node_rows_[node] * kThermalDofsPerNode + + ThermalComponentIndex(component); +} + std::optional DofManager::FreeEquation( const std::size_t full_dof) const { return free_equations_.at(full_dof); @@ -317,6 +471,11 @@ std::optional DofManager::FreeEquation( Result> DofManager::ElementScatter( const ElementDofLayout& layout) const { + if (profile_ != DofProfile::kStructural) { + return Result>::Failure(DofFailure( + "invalid-dof-profile", {}, layout.source_id.source_label_text, + "Structural layouts are invalid for a thermal mapping.")); + } if (layout.node_indices.empty() || layout.components_per_node.empty() || layout.node_indices.size() > (std::numeric_limits::max)() / layout.components_per_node.size()) { @@ -354,6 +513,45 @@ Result> DofManager::ElementScatter( return Result>::Success(std::move(scatter)); } +Result> DofManager::ThermalElementScatter( + const ThermalElementDofLayout& layout) const { + if (profile_ != DofProfile::kThermal) { + return Result>::Failure(DofFailure( + "invalid-dof-profile", {}, layout.source_id.source_label_text, + "Thermal layouts require a thermal mapping.")); + } + std::array components{}; + for (const auto component : layout.components) { + const std::size_t index = ThermalComponentIndex(component); + if (index >= components.size() || components[index]) { + return Result>::Failure(DofFailure( + "invalid-thermal-element-layout", {}, layout.source_id.source_label_text, + "Thermal layouts require each fixed station exactly once.")); + } + components[index] = true; + } + std::vector scatter; + scatter.reserve(layout.node_indices.size() * layout.components.size()); + for (const EntityIndex node : layout.node_indices) { + for (const auto component : layout.components) { + try { + scatter.push_back(FullThermalDof(node, component)); + } catch (const std::out_of_range&) { + return Result>::Failure(DofFailure( + "invalid-thermal-element-layout", {}, layout.source_id.source_label_text, + "Thermal layouts must reference active thermal nodes.")); + } + } + } + if (std::adjacent_find(layout.node_indices.begin(), layout.node_indices.end()) != + layout.node_indices.end()) { + return Result>::Failure(DofFailure( + "duplicate-element-dof", {}, layout.source_id.source_label_text, + "A thermal layout must not repeat a node.")); + } + return Result>::Success(std::move(scatter)); +} + std::array DofManager::ElementScatter( const EntityIndex element) const { return FixedScatter<12U>(element_scatters_.at(element)); diff --git a/tests/unit/fem/dof_manager_test.cpp b/tests/unit/fem/dof_manager_test.cpp index c4c0624..efc9b6a 100644 --- a/tests/unit/fem/dof_manager_test.cpp +++ b/tests/unit/fem/dof_manager_test.cpp @@ -5,10 +5,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include @@ -143,6 +145,29 @@ fesa::ModelDefinition MakeShellDefinition() { return definition; } +fesa::ModelDefinition MakeThermalDefinition() { + const std::filesystem::path source{"models/thermal-dof-manager.inp"}; + fesa::ModelDefinition definition{}; + definition.source_path = source; + for (std::int64_t label = 1; label <= 6; ++label) { + definition.nodes.push_back({{"Plate", label, std::to_string(label)}, + {static_cast(label), 0.0, 0.0}, + {source, static_cast(label)}}); + } + definition.thermal_materials = { + {{"", 1, "Thermal"}, "Thermal", 40.0, 7800.0, 500.0, {source, 20U}}}; + definition.thermal_sections = { + {{"", 1, "Section"}, "Section", 0.01, 0U, {source, 21U}}}; + definition.thermal_elements = { + {{"Plate", 10, "10"}, {0U, 1U, 2U, 3U}, 0U, {source, 30U}}, + {{"Plate", 20, "20"}, {1U, 2U, 3U, 4U}, 0U, {source, 31U}}}; + definition.thermal_model_boundaries = {{0U, 11, 1.0, {source, 40U}}, + {1U, 15, 2.0, {source, 41U}}}; + definition.thermal_step = {"Heat", 0.1, 1.0, 100U, false, + {{0U, 11, 3.0, {source, 50U}}}, {}, {source, 49U}}; + return definition; +} + struct DofFixture { fesa::DofManager dofs; }; @@ -409,3 +434,50 @@ TEST(DofManager, ReconstructsFullReducedRoundTrip) { EXPECT_DOUBLE_EQ(reconstructed[index], full[index]); } } + +TEST(ThermalDofManager, MapsOnlyConnectedNodesAndOverridesModelBoundary) { + auto domain = fesa::Domain::Create(MakeThermalDefinition()); + ASSERT_TRUE(domain.HasValue()); + auto model = fesa::AnalysisModel::Create(domain.Value()); + ASSERT_TRUE(model.HasValue()); + auto result = fesa::DofManager::Create(model.Value()); + ASSERT_TRUE(result.HasValue()); + auto dofs = std::move(result.Value()); + + EXPECT_EQ(dofs.Profile(), fesa::DofProfile::kThermal); + EXPECT_EQ(dofs.FullDofCount(), 25U); + EXPECT_EQ(dofs.ConstrainedDofs(), (std::vector{0U, 9U})); + ASSERT_EQ(dofs.PrescribedValues().Size(), 2U); + EXPECT_DOUBLE_EQ(dofs.PrescribedValues()[0U], 3.0); + EXPECT_DOUBLE_EQ(dofs.PrescribedValues()[1U], 2.0); + EXPECT_EQ(dofs.FullThermalDof(4U, fesa::ThermalDofComponent::kTop), 24U); + EXPECT_THROW(dofs.FullThermalDof(5U, fesa::ThermalDofComponent::kBottom), + std::out_of_range); + + const auto first = dofs.ThermalElementScatter( + {{"Plate", 10, "10"}, {0U, 1U, 2U, 3U}}); + ASSERT_TRUE(first.HasValue()); + EXPECT_EQ(first.Value(), + (std::vector{0U, 1U, 2U, 3U, 4U, 5U, 6U, 7U, 8U, + 9U, 10U, 11U, 12U, 13U, 14U, 15U, 16U, + 17U, 18U, 19U})); + EXPECT_EQ(RowColumns(dofs.GetSparsePattern(), 0U), + (std::vector{0U, 1U, 2U, 3U, 4U, 5U, 6U, 7U, 8U, + 9U, 10U, 11U, 12U, 13U, 14U, 15U, 16U, + 17U, 18U, 19U})); + + const auto free_before = dofs.FreeDofs(); + const auto constrained_before = dofs.ConstrainedDofs(); + const fesa::Status invalid = dofs.BuildThermal( + model.Value(), {{{"Plate", 10, "10"}, {0U, 1U, 2U, 3U}, + {fesa::ThermalDofComponent::kBottom, + fesa::ThermalDofComponent::kBottom, + fesa::ThermalDofComponent::kMiddle, + fesa::ThermalDofComponent::kUpper, + fesa::ThermalDofComponent::kTop}}, + {{"Plate", 20, "20"}, {1U, 2U, 3U, 4U}}}); + ASSERT_FALSE(invalid.IsOk()); + EXPECT_EQ(invalid.Diagnostics()[0U].code, "invalid-thermal-element-layout"); + EXPECT_EQ(dofs.FreeDofs(), free_before); + EXPECT_EQ(dofs.ConstrainedDofs(), constrained_before); +} From 17a2355159f85b5859fbc599894700d8cb2b807e Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 19:05:39 +0900 Subject: [PATCH 2/2] fix: validate thermal dof layouts --- src/fesa/fem/dof_manager.cpp | 66 ++++++++---- tests/unit/fem/dof_manager_test.cpp | 155 +++++++++++++++++++++++++--- 2 files changed, 187 insertions(+), 34 deletions(-) diff --git a/src/fesa/fem/dof_manager.cpp b/src/fesa/fem/dof_manager.cpp index cd178bd..f532553 100644 --- a/src/fesa/fem/dof_manager.cpp +++ b/src/fesa/fem/dof_manager.cpp @@ -53,7 +53,9 @@ std::vector FullNodeComponents() { bool IsThermalSurfaceDof(const int dof) { return dof == 11 || dof == 15; } std::size_t ThermalComponentIndex(const ThermalDofComponent component) { - return static_cast(static_cast(component) - 11); + const int source_dof = static_cast(component); + if (source_dof < 11 || source_dof > 15) return kThermalDofsPerNode; + return static_cast(source_dof - 11); } void AppendScatter(std::vector>& columns_by_row, @@ -157,8 +159,9 @@ Result DofManager::CreateThermal(const AnalysisModel& model) { Status DofManager::Build(const AnalysisModel& analysis_model, const ElementView& elements) { if (analysis_model.IsThermal()) { - return DofFailure("invalid-dof-profile", {analysis_model.GetDomain().SourcePath(), 0U}, - "structural", "Structural element layouts cannot build a thermal mapping."); + return DofFailure( + "invalid-dof-profile", {analysis_model.GetDomain().SourcePath(), 0U}, + "structural", "Structural element layouts cannot build a thermal mapping."); } return Build(analysis_model, elements, analysis_model.Step().BoundaryConditions()); @@ -169,8 +172,9 @@ Status DofManager::Build(const AnalysisModel& analysis_model, const BoundaryConditionView& boundaries) { std::vector layouts; if (analysis_model.IsThermal()) { - return DofFailure("invalid-dof-profile", {analysis_model.GetDomain().SourcePath(), 0U}, - "structural", "Structural element layouts cannot build a thermal mapping."); + return DofFailure( + "invalid-dof-profile", {analysis_model.GetDomain().SourcePath(), 0U}, + "structural", "Structural element layouts cannot build a thermal mapping."); } layouts.reserve(elements.size()); for (const auto& element : elements) { @@ -183,13 +187,16 @@ Status DofManager::BuildThermal( const AnalysisModel& analysis_model, const std::vector& layouts) { if (!analysis_model.IsThermal() || analysis_model.ThermalStep() == nullptr) { - return DofFailure("invalid-dof-profile", {analysis_model.GetDomain().SourcePath(), 0U}, - "thermal", "Thermal layouts require a thermal AnalysisModel."); + return DofFailure( + "invalid-dof-profile", {analysis_model.GetDomain().SourcePath(), 0U}, + "thermal", "Thermal layouts require a thermal AnalysisModel."); } const Domain& domain = analysis_model.GetDomain(); if (layouts.size() != analysis_model.ActiveThermalElements().size()) { - return DofFailure("invalid-element-layout-order", {domain.SourcePath(), 0U}, - std::to_string(layouts.size()), "Thermal layouts must match the active DS4 inventory."); + return DofFailure( + "invalid-element-layout-order", {domain.SourcePath(), 0U}, + std::to_string(layouts.size()), + "Thermal layouts must match the active DS4 inventory."); } DofManager candidate; candidate.profile_ = DofProfile::kThermal; @@ -198,8 +205,9 @@ Status DofManager::BuildThermal( ++row) { const EntityIndex node = analysis_model.ActiveThermalNodes()[row]; if (node >= candidate.thermal_node_rows_.size()) { - return DofFailure("invalid-thermal-node", {domain.SourcePath(), 0U}, - std::to_string(node), "Active thermal node is outside Domain nodes."); + return DofFailure( + "invalid-thermal-node", {domain.SourcePath(), 0U}, + std::to_string(node), "Active thermal node is outside Domain nodes."); } candidate.thermal_node_rows_[node] = row; } @@ -209,8 +217,9 @@ Status DofManager::BuildThermal( for (std::size_t index = 0U; index < layouts.size(); ++index) { const auto& definition = domain.ThermalElements()[index]; if (!SameSourceIdentity(layouts[index].source_id, definition.SourceId())) { - return DofFailure("invalid-element-layout-order", {domain.SourcePath(), 0U}, - std::to_string(index), "Thermal layout identity changed source order."); + return DofFailure( + "invalid-element-layout-order", {domain.SourcePath(), 0U}, + std::to_string(index), "Thermal layout identity changed source order."); } for (std::size_t node = 0U; node < layouts[index].node_indices.size(); ++node) { @@ -225,9 +234,10 @@ Status DofManager::BuildThermal( candidate.element_scatters_.push_back(scatter.Value()); } std::map effective; - const auto apply = [&candidate, &effective]( - const std::vector& boundaries, - const bool overrides) -> Status { + const auto apply = + [&candidate, &effective]( + const std::vector& boundaries, + const bool overrides) -> Status { for (const auto& boundary : boundaries) { if (!IsThermalSurfaceDof(boundary.dof) || !std::isfinite(boundary.value)) { return DofFailure("invalid-thermal-boundary", boundary.location, "BOUNDARY", @@ -521,6 +531,15 @@ Result> DofManager::ThermalElementScatter( "Thermal layouts require a thermal mapping.")); } std::array components{}; + const std::array canonical{ + {ThermalDofComponent::kBottom, ThermalDofComponent::kLower, + ThermalDofComponent::kMiddle, ThermalDofComponent::kUpper, + ThermalDofComponent::kTop}}; + if (layout.components != canonical) { + return Result>::Failure(DofFailure( + "invalid-thermal-element-layout", {}, layout.source_id.source_label_text, + "Thermal layouts must preserve canonical bottom-to-top stations.")); + } for (const auto component : layout.components) { const std::size_t index = ThermalComponentIndex(component); if (index >= components.size() || components[index]) { @@ -543,11 +562,16 @@ Result> DofManager::ThermalElementScatter( } } } - if (std::adjacent_find(layout.node_indices.begin(), layout.node_indices.end()) != - layout.node_indices.end()) { - return Result>::Failure(DofFailure( - "duplicate-element-dof", {}, layout.source_id.source_label_text, - "A thermal layout must not repeat a node.")); + for (std::size_t index = 0U; index < layout.node_indices.size(); ++index) { + if (std::find(layout.node_indices.begin(), + layout.node_indices.begin() + + static_cast(index), + layout.node_indices[index]) != + layout.node_indices.begin() + static_cast(index)) { + return Result>::Failure(DofFailure( + "duplicate-element-dof", {}, layout.source_id.source_label_text, + "A thermal layout must not repeat a node.")); + } } return Result>::Success(std::move(scatter)); } diff --git a/tests/unit/fem/dof_manager_test.cpp b/tests/unit/fem/dof_manager_test.cpp index efc9b6a..c93823c 100644 --- a/tests/unit/fem/dof_manager_test.cpp +++ b/tests/unit/fem/dof_manager_test.cpp @@ -149,18 +149,20 @@ fesa::ModelDefinition MakeThermalDefinition() { const std::filesystem::path source{"models/thermal-dof-manager.inp"}; fesa::ModelDefinition definition{}; definition.source_path = source; - for (std::int64_t label = 1; label <= 6; ++label) { - definition.nodes.push_back({{"Plate", label, std::to_string(label)}, - {static_cast(label), 0.0, 0.0}, - {source, static_cast(label)}}); - } + definition.nodes = {{{"Plate", 1, "1"}, {0.0, 0.0, 0.0}, {source, 1U}}, + {{"Plate", 2, "2"}, {1.0, 0.0, 0.0}, {source, 2U}}, + {{"Plate", 3, "3"}, {1.0, 1.0, 0.0}, {source, 3U}}, + {{"Plate", 7, "7"}, {3.0, 3.0, 0.0}, {source, 7U}}, + {{"Plate", 4, "4"}, {0.0, 1.0, 0.0}, {source, 4U}}, + {{"Plate", 5, "5"}, {2.0, 0.0, 0.0}, {source, 5U}}, + {{"Plate", 6, "6"}, {2.0, 1.0, 0.0}, {source, 6U}}}; definition.thermal_materials = { {{"", 1, "Thermal"}, "Thermal", 40.0, 7800.0, 500.0, {source, 20U}}}; definition.thermal_sections = { {{"", 1, "Section"}, "Section", 0.01, 0U, {source, 21U}}}; definition.thermal_elements = { - {{"Plate", 10, "10"}, {0U, 1U, 2U, 3U}, 0U, {source, 30U}}, - {{"Plate", 20, "20"}, {1U, 2U, 3U, 4U}, 0U, {source, 31U}}}; + {{"Plate", 10, "10"}, {0U, 1U, 2U, 4U}, 0U, {source, 30U}}, + {{"Plate", 20, "20"}, {1U, 5U, 6U, 2U}, 0U, {source, 31U}}}; definition.thermal_model_boundaries = {{0U, 11, 1.0, {source, 40U}}, {1U, 15, 2.0, {source, 41U}}}; definition.thermal_step = {"Heat", 0.1, 1.0, 100U, false, @@ -445,39 +447,166 @@ TEST(ThermalDofManager, MapsOnlyConnectedNodesAndOverridesModelBoundary) { auto dofs = std::move(result.Value()); EXPECT_EQ(dofs.Profile(), fesa::DofProfile::kThermal); - EXPECT_EQ(dofs.FullDofCount(), 25U); + EXPECT_EQ(dofs.FullDofCount(), 30U); + EXPECT_THROW(dofs.FullDof(0U, fesa::DofComponent::kUx), std::logic_error); EXPECT_EQ(dofs.ConstrainedDofs(), (std::vector{0U, 9U})); ASSERT_EQ(dofs.PrescribedValues().Size(), 2U); EXPECT_DOUBLE_EQ(dofs.PrescribedValues()[0U], 3.0); EXPECT_DOUBLE_EQ(dofs.PrescribedValues()[1U], 2.0); - EXPECT_EQ(dofs.FullThermalDof(4U, fesa::ThermalDofComponent::kTop), 24U); - EXPECT_THROW(dofs.FullThermalDof(5U, fesa::ThermalDofComponent::kBottom), + EXPECT_EQ(dofs.FullThermalDof(6U, fesa::ThermalDofComponent::kTop), 29U); + EXPECT_THROW(dofs.FullThermalDof(3U, fesa::ThermalDofComponent::kBottom), std::out_of_range); + EXPECT_THROW( + dofs.FullThermalDof( + 0U, static_cast( + (std::numeric_limits::min)())), + std::out_of_range); const auto first = dofs.ThermalElementScatter( - {{"Plate", 10, "10"}, {0U, 1U, 2U, 3U}}); + {{"Plate", 10, "10"}, {0U, 1U, 2U, 4U}}); ASSERT_TRUE(first.HasValue()); EXPECT_EQ(first.Value(), (std::vector{0U, 1U, 2U, 3U, 4U, 5U, 6U, 7U, 8U, 9U, 10U, 11U, 12U, 13U, 14U, 15U, 16U, 17U, 18U, 19U})); + const auto second = dofs.ThermalElementScatter( + {{"Plate", 20, "20"}, {1U, 5U, 6U, 2U}}); + ASSERT_TRUE(second.HasValue()); + EXPECT_EQ(std::vector(second.Value().begin(), + second.Value().begin() + 5U), + (std::vector{5U, 6U, 7U, 8U, 9U})); + EXPECT_EQ(std::vector(second.Value().end() - 5U, + second.Value().end()), + (std::vector{10U, 11U, 12U, 13U, 14U})); + const fesa::ElementDofLayout structural_layout{ + {"Plate", 10, "10"}, {0U}, {fesa::DofComponent::kUx}}; + EXPECT_FALSE(dofs.ElementScatter(structural_layout).HasValue()); + const auto repeated = dofs.ThermalElementScatter( + {{"Plate", 10, "10"}, {0U, 1U, 0U, 4U}}); + ASSERT_FALSE(repeated.HasValue()); + EXPECT_EQ(repeated.GetStatus().Diagnostics()[0U].code, "duplicate-element-dof"); + const auto reordered = dofs.ThermalElementScatter( + {{"Plate", 10, "10"}, + {0U, 1U, 2U, 4U}, + {fesa::ThermalDofComponent::kTop, fesa::ThermalDofComponent::kLower, + fesa::ThermalDofComponent::kMiddle, fesa::ThermalDofComponent::kUpper, + fesa::ThermalDofComponent::kBottom}}); + ASSERT_FALSE(reordered.HasValue()); + const auto invalid_component = dofs.ThermalElementScatter( + {{"Plate", 10, "10"}, + {0U, 1U, 2U, 4U}, + {static_cast((std::numeric_limits::min)()), + fesa::ThermalDofComponent::kLower, fesa::ThermalDofComponent::kMiddle, + fesa::ThermalDofComponent::kUpper, fesa::ThermalDofComponent::kTop}}); + ASSERT_FALSE(invalid_component.HasValue()); EXPECT_EQ(RowColumns(dofs.GetSparsePattern(), 0U), (std::vector{0U, 1U, 2U, 3U, 4U, 5U, 6U, 7U, 8U, 9U, 10U, 11U, 12U, 13U, 14U, 15U, 16U, 17U, 18U, 19U})); + const auto profile_before = dofs.Profile(); + const auto full_count_before = dofs.FullDofCount(); + const auto free_count_before = dofs.FreeDofCount(); + const auto constrained_count_before = dofs.ConstrainedDofCount(); const auto free_before = dofs.FreeDofs(); const auto constrained_before = dofs.ConstrainedDofs(); + const auto prescribed_before = dofs.PrescribedValues(); + const auto pattern_before = dofs.GetSparsePattern(); + const auto scatter_before = dofs.ThermalElementScatter( + {{"Plate", 10, "10"}, {0U, 1U, 2U, 4U}}); + ASSERT_TRUE(scatter_before.HasValue()); const fesa::Status invalid = dofs.BuildThermal( - model.Value(), {{{"Plate", 10, "10"}, {0U, 1U, 2U, 3U}, + model.Value(), {{{"Plate", 10, "10"}, {0U, 1U, 2U, 4U}, {fesa::ThermalDofComponent::kBottom, fesa::ThermalDofComponent::kBottom, fesa::ThermalDofComponent::kMiddle, fesa::ThermalDofComponent::kUpper, fesa::ThermalDofComponent::kTop}}, - {{"Plate", 20, "20"}, {1U, 2U, 3U, 4U}}}); + {{"Plate", 20, "20"}, {1U, 5U, 6U, 2U}}}); ASSERT_FALSE(invalid.IsOk()); EXPECT_EQ(invalid.Diagnostics()[0U].code, "invalid-thermal-element-layout"); + EXPECT_EQ(dofs.Profile(), profile_before); + EXPECT_EQ(dofs.FullDofCount(), full_count_before); + EXPECT_EQ(dofs.FreeDofCount(), free_count_before); + EXPECT_EQ(dofs.ConstrainedDofCount(), constrained_count_before); EXPECT_EQ(dofs.FreeDofs(), free_before); EXPECT_EQ(dofs.ConstrainedDofs(), constrained_before); + ASSERT_EQ(dofs.PrescribedValues().Size(), prescribed_before.Size()); + for (std::size_t index = 0U; index < prescribed_before.Size(); ++index) { + EXPECT_DOUBLE_EQ(dofs.PrescribedValues()[index], prescribed_before[index]); + } + EXPECT_EQ(dofs.GetSparsePattern().row_offsets, pattern_before.row_offsets); + EXPECT_EQ(dofs.GetSparsePattern().column_indices, + pattern_before.column_indices); + const auto scatter_after = dofs.ThermalElementScatter( + {{"Plate", 10, "10"}, {0U, 1U, 2U, 4U}}); + ASSERT_TRUE(scatter_after.HasValue()); + EXPECT_EQ(scatter_after.Value(), scatter_before.Value()); + + const fesa::ElementView no_structural_elements; + const fesa::Status structural_misuse = + dofs.Build(model.Value(), no_structural_elements); + ASSERT_FALSE(structural_misuse.IsOk()); + EXPECT_EQ(structural_misuse.Diagnostics()[0U].code, "invalid-dof-profile"); + EXPECT_EQ(dofs.GetSparsePattern().row_offsets, pattern_before.row_offsets); +} + +TEST(ThermalDofManager, RejectsChangedThermalLayoutInventory) { + auto domain = fesa::Domain::Create(MakeThermalDefinition()); + ASSERT_TRUE(domain.HasValue()); + auto model = fesa::AnalysisModel::Create(domain.Value()); + ASSERT_TRUE(model.HasValue()); + auto result = fesa::DofManager::CreateThermal(model.Value()); + ASSERT_TRUE(result.HasValue()); + auto dofs = std::move(result.Value()); + + const std::vector layouts{ + {{"Plate", 10, "10"}, {0U, 1U, 2U, 4U}}, + {{"Plate", 20, "20"}, {1U, 5U, 6U, 2U}}}; + auto changed_identity = layouts; + changed_identity[0U].source_id = {"Plate", 99, "99"}; + EXPECT_FALSE(dofs.BuildThermal(model.Value(), changed_identity).IsOk()); + + auto changed_order = layouts; + std::swap(changed_order[0U], changed_order[1U]); + EXPECT_FALSE(dofs.BuildThermal(model.Value(), changed_order).IsOk()); + + auto changed_nodes = layouts; + changed_nodes[1U].node_indices = {1U, 6U, 5U, 2U}; + EXPECT_FALSE(dofs.BuildThermal(model.Value(), changed_nodes).IsOk()); +} + +TEST(ThermalDofManager, KeepsDisconnectedThermalComponentsInSeparateCsrBlocks) { + auto definition = MakeThermalDefinition(); + definition.nodes.push_back({{"Plate", 8, "8"}, {3.0, 0.0, 0.0}, + {definition.source_path, 8U}}); + definition.nodes.push_back({{"Plate", 9, "9"}, {3.0, 1.0, 0.0}, + {definition.source_path, 9U}}); + definition.thermal_elements[1U].node_indices = {5U, 7U, 8U, 6U}; + definition.thermal_model_boundaries.clear(); + definition.thermal_step->boundaries.clear(); + auto domain = fesa::Domain::Create(std::move(definition)); + ASSERT_TRUE(domain.HasValue()); + auto model = fesa::AnalysisModel::Create(domain.Value()); + ASSERT_TRUE(model.HasValue()); + auto result = fesa::DofManager::Create(model.Value()); + ASSERT_TRUE(result.HasValue()); + EXPECT_EQ(result.Value().FullDofCount(), 40U); + EXPECT_EQ(RowColumns(result.Value().GetSparsePattern(), 0U).size(), 20U); + EXPECT_EQ(RowColumns(result.Value().GetSparsePattern(), 20U).front(), 20U); +} + +TEST(ThermalDofManager, RejectsThermalLayoutsForStructuralModels) { + auto domain = fesa::Domain::Create(MakeDefinition()); + ASSERT_TRUE(domain.HasValue()); + auto model = fesa::AnalysisModel::Create(domain.Value()); + ASSERT_TRUE(model.HasValue()); + fesa::DofManager dofs; + const fesa::Status status = dofs.BuildThermal(model.Value(), {}); + ASSERT_FALSE(status.IsOk()); + EXPECT_EQ(status.Diagnostics()[0U].code, "invalid-dof-profile"); + const auto thermal_create = fesa::DofManager::CreateThermal(model.Value()); + ASSERT_FALSE(thermal_create.HasValue()); + EXPECT_EQ(thermal_create.GetStatus().Diagnostics()[0U].code, + "invalid-dof-profile"); }