From e71f3ad9ac0f0cd08cb4fb48c6e309ba38217405 Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 18:59:04 +0900 Subject: [PATCH] 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); +}