feat: add thermal dof mapping

This commit is contained in:
KOKO\Mimi
2026-09-29 18:59:04 +09:00
parent 878a71bd96
commit e71f3ad9ac
4 changed files with 313 additions and 0 deletions
@@ -0,0 +1,22 @@
#ifndef FESA_ELEMENTS_THERMAL_ELEMENT_LAYOUT_H_
#define FESA_ELEMENTS_THERMAL_ELEMENT_LAYOUT_H_
#include <array>
#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<EntityIndex, 4> node_indices;
std::array<ThermalDofComponent, 5> components{
{ThermalDofComponent::kBottom, ThermalDofComponent::kLower,
ThermalDofComponent::kMiddle, ThermalDofComponent::kUpper,
ThermalDofComponent::kTop}};
};
} // namespace fesa
#endif // FESA_ELEMENTS_THERMAL_ELEMENT_LAYOUT_H_
+21
View File
@@ -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<std::size_t> row_offsets;
@@ -32,6 +35,9 @@ class DofManager {
/// the procedure-owned runtime element view supersedes it in Step 20.
static Result<DofManager> Create(const AnalysisModel& model);
/// @brief Builds the active DS4 five-station equation mapping.
static Result<DofManager> 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<ThermalElementDofLayout>& 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<std::size_t> 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<std::vector<std::size_t>> ElementScatter(
const ElementDofLayout& layout) const;
/// @brief Maps one DS4 layout to its node-major 20-entry scatter.
Result<std::vector<std::size_t>> 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<std::size_t, 12> ElementScatter(EntityIndex element) const;
@@ -100,6 +119,8 @@ class DofManager {
std::vector<std::size_t> constrained_dofs_;
Vector prescribed_values_{0U};
SparsePattern sparse_pattern_;
DofProfile profile_{DofProfile::kStructural};
std::vector<std::optional<std::size_t>> thermal_node_rows_;
};
} // namespace fesa
+198
View File
@@ -5,6 +5,7 @@
#include <cmath>
#include <cstddef>
#include <limits>
#include <map>
#include <stdexcept>
#include <string>
#include <utility>
@@ -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<DofComponent> 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<std::size_t>(static_cast<int>(component) - 11);
}
void AppendScatter(std::vector<std::vector<std::size_t>>& columns_by_row,
const std::vector<std::size_t>& scatter) {
for (const std::size_t row : scatter) {
@@ -94,6 +102,7 @@ std::array<std::size_t, kSize> FixedScatter(
} // namespace
Result<DofManager> DofManager::Create(const AnalysisModel& model) {
if (model.IsThermal()) return CreateThermal(model);
const Domain& domain = model.GetDomain();
std::vector<ElementDofLayout> layouts;
layouts.reserve(model.ActiveElements().size());
@@ -118,8 +127,39 @@ Result<DofManager> DofManager::Create(const AnalysisModel& model) {
return Result<DofManager>::Success(std::move(dofs));
}
Result<DofManager> DofManager::CreateThermal(const AnalysisModel& model) {
if (!model.IsThermal()) {
return Result<DofManager>::Failure(DofFailure(
"invalid-dof-profile", {model.GetDomain().SourcePath(), 0U}, "thermal",
"Thermal mapping requires a thermal AnalysisModel."));
}
const Domain& domain = model.GetDomain();
std::vector<ThermalElementDofLayout> layouts;
layouts.reserve(model.ActiveThermalElements().size());
for (std::size_t index = 0U; index < model.ActiveThermalElements().size();
++index) {
if (index >= domain.ThermalElements().Size()) {
return Result<DofManager>::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<DofManager>::Failure(status);
return Result<DofManager>::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<ElementDofLayout> 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<ThermalElementDofLayout>& 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<std::size_t, double> effective;
const auto apply = [&candidate, &effective](
const std::vector<ThermalBoundaryConditionDefinition>& 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<ThermalDofComponent>(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<ElementDofLayout>& 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<std::size_t>(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<std::size_t>(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<std::size_t> DofManager::FreeEquation(
const std::size_t full_dof) const {
return free_equations_.at(full_dof);
@@ -317,6 +471,11 @@ std::optional<std::size_t> DofManager::FreeEquation(
Result<std::vector<std::size_t>> DofManager::ElementScatter(
const ElementDofLayout& layout) const {
if (profile_ != DofProfile::kStructural) {
return Result<std::vector<std::size_t>>::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<std::size_t>::max)() /
layout.components_per_node.size()) {
@@ -354,6 +513,45 @@ Result<std::vector<std::size_t>> DofManager::ElementScatter(
return Result<std::vector<std::size_t>>::Success(std::move(scatter));
}
Result<std::vector<std::size_t>> DofManager::ThermalElementScatter(
const ThermalElementDofLayout& layout) const {
if (profile_ != DofProfile::kThermal) {
return Result<std::vector<std::size_t>>::Failure(DofFailure(
"invalid-dof-profile", {}, layout.source_id.source_label_text,
"Thermal layouts require a thermal mapping."));
}
std::array<bool, kThermalDofsPerNode> components{};
for (const auto component : layout.components) {
const std::size_t index = ThermalComponentIndex(component);
if (index >= components.size() || components[index]) {
return Result<std::vector<std::size_t>>::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<std::size_t> 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<std::vector<std::size_t>>::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<std::vector<std::size_t>>::Failure(DofFailure(
"duplicate-element-dof", {}, layout.source_id.source_label_text,
"A thermal layout must not repeat a node."));
}
return Result<std::vector<std::size_t>>::Success(std::move(scatter));
}
std::array<std::size_t, 12> DofManager::ElementScatter(
const EntityIndex element) const {
return FixedScatter<12U>(element_scatters_.at(element));
+72
View File
@@ -5,10 +5,12 @@
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <optional>
#include <string>
#include <stdexcept>
#include <utility>
#include <vector>
@@ -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<double>(label), 0.0, 0.0},
{source, static_cast<std::size_t>(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<std::size_t>{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<std::size_t>{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<std::size_t>{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);
}