feat: integrate certified transient heat procedure

This commit is contained in:
KOKO\Mimi
2026-09-29 21:44:23 +09:00
6 changed files with 1248 additions and 0 deletions
@@ -0,0 +1,39 @@
#ifndef FESA_ANALYSIS_TRANSIENT_HEAT_PROCEDURE_H_
#define FESA_ANALYSIS_TRANSIENT_HEAT_PROCEDURE_H_
#include <vector>
#include "fesa/analysis/thermal_analysis_state.h"
#include "fesa/core/status.h"
namespace fesa {
class LinearSolver;
class ParallelFor;
/// @brief Owns complete thermal history before any result output is opened.
struct ThermalExecutionResult {
ThermalAnalysisState state;
std::vector<Diagnostic> diagnostics;
};
/// @brief Executes fixed-increment backward Euler with checked thermal
/// recovery.
class TransientHeatProcedureExecutor final {
public:
/// @brief Retains caller-owned adapters, which must outlive Execute calls.
TransientHeatProcedureExecutor(const ParallelFor& parallel_for,
LinearSolver& linear_solver);
/// @brief Returns a complete independently owned state or a structured
/// failure.
/// @note Uses raw initial values; prescribed temperatures start at increment
/// one. No output files are opened. A failed trial is never committed to
/// history.
Result<ThermalExecutionResult> Execute(const Domain& domain) const;
private:
const ParallelFor& parallel_for_;
LinearSolver& linear_solver_;
};
} // namespace fesa
#endif // FESA_ANALYSIS_TRANSIENT_HEAT_PROCEDURE_H_
@@ -0,0 +1,503 @@
#include "thermal_transient_algebra.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <string>
#include <utility>
#include "thermal_transient_bounds.h"
namespace fesa::thermal_transient_internal {
namespace {
using bounds::Abs;
using bounds::Add;
using bounds::Div;
using bounds::Gamma;
using bounds::I;
using bounds::Magnitude;
using bounds::Mul;
using bounds::Roundoff;
using bounds::Sub;
using bounds::Tiny;
using bounds::Zero;
/// @brief Reports certification failure without publishing a candidate.
Status Failure(const std::string& message) {
return Status::Failure(FailureCategory::kSolver, {{Severity::kError,
"thermal-increment-failed",
{},
"HEAT TRANSFER",
"",
message}});
}
/// @brief Checks every scalar while preserving signed temperatures and powers.
bool Finite(const Vector& vector) {
for (std::size_t i = 0; i < vector.Size(); ++i)
if (!std::isfinite(vector[i])) return false;
return true;
}
/// @brief Checks aligned full operators and their coefficient-error storage.
bool SamePattern(const SparseMatrix& a, const SparseMatrix& b) {
return a.Rows() == b.Rows() && a.Columns() == b.Columns() &&
a.RowOffsets() == b.RowOffsets() &&
a.ColumnIndices() == b.ColumnIndices();
}
/// @brief Encloses a componentwise maximum for infinity norms.
I Max(I a, I b) {
return {std::max(a.lower, b.lower), std::max(a.upper, b.upper)};
}
/// @brief Encloses the exact infinity norm of stored scalar entries.
I Norm(const Vector& v) {
I result;
for (std::size_t i = 0; i < v.Size(); ++i)
result = Max(result, Magnitude(v[i]));
return result;
}
/// @brief Bounds construction error against the independently enclosed value.
double Error(I exact, double rounded) {
return Abs(Sub(exact, I{rounded})).upper;
}
/// @brief Checks optional entrywise coefficient bounds without inventing
/// defaults.
bool Nonnegative(const SparseMatrix* error, const SparseMatrix& matrix) {
if (!error) return true;
if (!error->Validate().IsOk() || !SamePattern(*error, matrix)) return false;
for (const double x : error->Values())
if (x < 0) return false;
return true;
}
/// @brief Validates the private generic algebra boundary before indexing.
Status Validate(const ThermalSystem& s, const ThermalPartition& p,
const Vector& old, double delta) {
const auto m = s.k.Rows();
if (!s.k.Validate().IsOk() || !s.c.Validate().IsOk() ||
!SamePattern(s.k, s.c) || s.k.Columns() != m || s.q.Size() != m ||
old.Size() != m || !Finite(s.q) || !Finite(old) ||
!Finite(p.prescribed) || !std::isfinite(delta) || delta <= 0 ||
p.prescribed.Size() != p.constrained.size() ||
p.free.size() + p.constrained.size() != m ||
!Nonnegative(s.k_error, s.k) || !Nonnegative(s.c_error, s.c))
return Failure(
"Invalid finite thermal operators, partition, or actual increment.");
if (s.q_error) {
if (s.q_error->Size() != m || !Finite(*s.q_error))
return Failure("Invalid power error bounds.");
for (std::size_t i = 0; i < m; ++i)
if ((*s.q_error)[i] < 0) return Failure("Negative power error bound.");
}
std::vector<bool> used(m, false);
for (const auto* rows : {&p.free, &p.constrained}) {
if (!std::is_sorted(rows->begin(), rows->end()))
return Failure("Partition order is invalid.");
for (const auto row : *rows) {
if (row >= m || used[row])
return Failure("Partition is not disjoint and exhaustive.");
used[row] = true;
}
}
return Status::Ok();
}
/// @brief Keeps rounded solve data and separate exact-algebra enclosures.
struct Equations {
SparseMatrix a;
Vector b;
std::vector<I> exact_a;
std::vector<I> exact_b;
};
/// @brief Forms the reduced operator and raw-old-state backward Euler RHS.
Result<Equations> Build(const ThermalSystem& s, const ThermalPartition& p,
const Vector& old, double delta) {
const auto n = p.free.size(), m = old.Size();
std::vector<std::size_t> free(m, n), constrained(m, p.constrained.size());
for (std::size_t i = 0; i < n; ++i) free[p.free[i]] = i;
for (std::size_t i = 0; i < p.constrained.size(); ++i)
constrained[p.constrained[i]] = i;
SparsePattern pattern{{0}, {}};
std::vector<CooContribution> coo;
Vector b{n};
std::vector<I> exact_a, exact_b(n);
for (std::size_t f = 0; f < n; ++f) {
const auto row = p.free[f];
b[f] = s.q[row];
exact_b[f] = I{s.q[row]};
for (auto j = s.c.RowOffsets()[row]; j < s.c.RowOffsets()[row + 1]; ++j) {
const auto col = s.c.ColumnIndices()[j];
const double c = s.c.Values()[j], k = s.k.Values()[j];
const I cd = Div(I{c}, I{delta});
if (free[col] != n) {
coo.push_back({f, free[col], c / delta + k, 0, coo.size()});
pattern.column_indices.push_back(free[col]);
exact_a.push_back(Add(cd, I{k}));
b[f] += (c / delta) * old[col];
exact_b[f] = Add(exact_b[f], Mul(cd, I{old[col]}));
} else {
const double g = p.prescribed[constrained[col]];
// Preserve raw old constrained capacity; never project frame zero.
b[f] += (c / delta) * (old[col] - g) - k * g;
exact_b[f] = Add(exact_b[f],
Sub(Mul(cd, Sub(I{old[col]}, I{g})), Mul(I{k}, I{g})));
}
}
pattern.row_offsets.push_back(coo.size());
}
auto a = SparseMatrix::FromCoo(n, n, std::move(coo), pattern);
if (!a.HasValue()) return Result<Equations>::Failure(a.GetStatus());
if (!Finite(b))
return Result<Equations>::Failure(Failure("Nonfinite backward Euler RHS."));
return Result<Equations>::Success({std::move(a.Value()), std::move(b),
std::move(exact_a), std::move(exact_b)});
}
/// @brief Certifies a residual using the reduced denominator and exact-zero
/// rule.
bool Certificate(double numerator, I denominator, double limit, double& beta) {
if (!std::isfinite(numerator) || !denominator.IsFinite()) return false;
if (denominator.lower <= 0) {
beta = 0;
return Zero(denominator) && numerator == 0;
}
beta = Div(I{numerator}, I{denominator.lower}).upper;
return std::isfinite(beta) && beta <= limit;
}
/// @brief Checks reduced/full residuals, physical heat balance, and BE
/// dissipation.
/// @note Coefficient errors enter forward evidence only, never the solve
/// tolerance.
Status Check(const ThermalSystem& s, const ThermalPartition& p,
const Vector& old, double delta, const Equations& eq,
ThermalStepCandidate& candidate) {
const auto m = old.Size(), n = p.free.size();
const double md = static_cast<double>(m), nd = static_cast<double>(n);
auto& ev = candidate.evidence;
const I beta_limit =
Add(Mul(I{nd}, Gamma(nd + 1)), Add(Gamma(2 * nd + 4), Gamma(4 * nd + 8)));
ev.beta_limit = beta_limit.lower;
I a_norm, t_norm, b_norm = Norm(eq.b);
Vector red{n}, red_error{n};
std::vector<I> construction(n);
for (std::size_t f = 0; f < n; ++f) {
I row_norm, scale, ea, identity;
double dot = 0;
for (auto j = eq.a.RowOffsets()[f]; j < eq.a.RowOffsets()[f + 1]; ++j) {
const double a = eq.a.Values()[j],
t = candidate.temperature[p.free[eq.a.ColumnIndices()[j]]];
dot += a * t;
row_norm = Add(row_norm, Magnitude(a));
scale = Add(scale, Mul(Magnitude(a), Magnitude(t)));
const I error{Error(eq.exact_a[j], a)};
ea = Add(ea, error);
identity = Add(identity, Mul(Sub(eq.exact_a[j], I{a}), I{t}));
}
a_norm = Max(a_norm, row_norm);
t_norm = Max(t_norm, Magnitude(candidate.temperature[p.free[f]]));
red[f] = dot - eq.b[f];
scale = Add(scale, Magnitude(eq.b[f]));
const double nu =
static_cast<double>(eq.a.RowOffsets()[f + 1] - eq.a.RowOffsets()[f]);
red_error[f] = Roundoff(2 * nu + 2, scale).upper;
ev.free_residual_cert = std::max(
ev.free_residual_cert, Add(Magnitude(red[f]), I{red_error[f]}).upper);
ev.operator_construction_error =
std::max(ev.operator_construction_error, ea.upper);
const double eb = Error(eq.exact_b[f], eq.b[f]);
ev.rhs_construction_error = std::max(ev.rhs_construction_error, eb);
construction[f] = Sub(identity, Sub(eq.exact_b[f], I{eq.b[f]}));
}
const I denominator = Add(Mul(a_norm, t_norm), b_norm);
if (n && !Certificate(ev.free_residual_cert, denominator, ev.beta_limit,
ev.beta_cert))
return Failure(
"Reduced residual is not certified on the reduced-system scale.");
Vector change{m}, full_error{m};
for (std::size_t i = 0; i < m; ++i)
change[i] = candidate.temperature[i] - old[i];
if (!Finite(change)) return Failure("Nonfinite temperature change.");
I c_change, k_new, q_abs, c_temperatures, underflow_sum;
std::vector<I> k_columns(m);
double energy = 0;
for (std::size_t i = 0; i < m; ++i) {
double cd = 0, kt = 0, ksum = 0;
I cs, ks, ts, cabs, nullscale, nullerror, coefficient;
for (auto j = s.c.RowOffsets()[i]; j < s.c.RowOffsets()[i + 1]; ++j) {
const auto col = s.c.ColumnIndices()[j];
const double c = s.c.Values()[j], k = s.k.Values()[j],
t = candidate.temperature[col];
cd += c * change[col];
kt += k * t;
ksum += k;
cs = Add(cs, Mul(Magnitude(c), Magnitude(change[col])));
ks = Add(ks, Mul(Magnitude(k), Magnitude(t)));
ts = Add(ts, Mul(Magnitude(c), Add(Magnitude(t), Magnitude(old[col]))));
cabs = Add(cabs, Magnitude(c));
nullscale = Add(nullscale, Magnitude(k));
k_columns[col] = Add(k_columns[col], I{k});
if (s.k_error) {
nullerror = Add(nullerror, I{s.k_error->Values()[j]});
coefficient =
Add(coefficient, Mul(I{s.k_error->Values()[j]}, Magnitude(t)));
}
if (s.c_error)
coefficient =
Add(coefficient,
Div(Mul(I{s.c_error->Values()[j]}, Magnitude(change[col])),
I{delta}));
}
energy += cd;
candidate.residual[i] = (cd / delta + kt) - s.q[i];
const double nu =
static_cast<double>(s.c.RowOffsets()[i + 1] - s.c.RowOffsets()[i]);
const I scale = Add(Div(cs, I{delta}), Add(ks, Magnitude(s.q[i])));
// Propagate underflow in the capacity dot product through division, and
// underflow in the old/new subtraction through each capacity coefficient.
I under = Add(Div(Tiny(2 * nu + 2, cs), I{delta}),
Tiny(2 * nu + 4, Add(ks, Magnitude(s.q[i]))));
under = Add(under, Div(Mul(cabs, Tiny(1, ts)), I{delta}));
full_error[i] = Add(Add(Mul(Gamma(4 * nu + 6), scale),
Div(Mul(Gamma(1), ts), I{delta})),
under)
.upper;
if (s.q_error) coefficient = Add(coefficient, I{(*s.q_error)[i]});
candidate.certified_residual[i] =
Add(Add(Magnitude(candidate.residual[i]), I{full_error[i]}),
coefficient)
.upper;
const I nullbound = Add(nullerror, Roundoff(nu, nullscale));
if (!nullbound.IsFinite() || std::abs(ksum) > nullbound.upper)
return Failure("Conductivity does not certify its constant null mode.");
c_change = Add(c_change, cs);
k_new = Add(k_new, ks);
q_abs = Add(q_abs, Magnitude(s.q[i]));
c_temperatures = Add(c_temperatures, ts);
underflow_sum = Add(underflow_sum, under);
}
if (!Finite(candidate.residual) || !Finite(candidate.certified_residual) ||
!std::isfinite(energy))
return Failure("Nonfinite full residual or residual enclosure.");
for (std::size_t f = 0; f < n; ++f) {
const auto row = p.free[f];
const I direct =
Add(I{candidate.residual[row]}, I{-full_error[row], full_error[row]});
const I transferred =
Add(Add(I{red[f]}, I{-red_error[f], red_error[f]}), construction[f]);
const I intersection{std::max(direct.lower, transferred.lower),
std::min(direct.upper, transferred.upper)};
if (!direct.IsFinite() || !transferred.IsFinite() ||
!intersection.IsFinite())
return Failure(
"Independent full residual enclosures have empty intersection.");
ev.full_free_residual_cert =
std::max(ev.full_free_residual_cert, Abs(intersection).upper);
const I bound =
Add(Abs(construction[f]), Add(I{red_error[f]}, I{full_error[row]}));
ev.construction_identity_bound =
std::max(ev.construction_identity_bound, bound.upper);
if (!bound.IsFinite() ||
Abs(Sub(I{candidate.residual[row]}, I{red[f]})).lower > bound.upper)
return Failure(
"Full/reduced equation construction identity is not enclosed.");
}
if (n && !Certificate(ev.full_free_residual_cert, denominator, ev.beta_limit,
ev.full_beta_cert))
return Failure(
"Full free residual is not certified on the reduced-system scale.");
double qsum = 0, rsum = 0;
I rabs;
for (std::size_t i = 0; i < m; ++i) qsum += s.q[i];
for (std::size_t i = 0; i < p.constrained.size(); ++i) {
candidate.reaction[i] = candidate.residual[p.constrained[i]];
rsum += candidate.reaction[i];
rabs = Add(rabs, Magnitude(candidate.reaction[i]));
}
I column_norm;
for (const auto x : k_columns) column_norm = Add(column_norm, Abs(x));
const I se = Add(c_change, Mul(I{delta}, Add(q_abs, rabs)));
I be = Mul(Mul(Mul(I{delta}, I{nd}), beta_limit), denominator);
be = Add(be, Mul(Mul(I{delta}, column_norm), Norm(candidate.temperature)));
be = Add(be, Roundoff(4 * md + 12, se));
be = Add(be, Mul(Mul(I{delta}, Gamma(2 * md + 6)),
Add(Div(c_change, I{delta}), Add(k_new, q_abs))));
be = Add(be, Mul(Gamma(1), c_temperatures));
be = Add(be, Mul(I{delta}, underflow_sum));
ev.energy_change = energy;
ev.energy_defect = std::abs(energy - delta * (qsum + rsum));
ev.energy_bound = be.upper;
if (!be.IsFinite() || !std::isfinite(ev.energy_defect) ||
ev.energy_defect > be.upper)
return Failure(
"Physical heat balance failed independently of the free residual.");
if (p.constrained.empty() && Zero(q_abs)) {
ev.dissipation_checked = true;
double cn = 0, co = 0, cd = 0, kn = 0;
I sc, subscale, t_one, d_square, o_square, skew_square, quadratic_underflow;
for (std::size_t i = 0; i < m; ++i) {
const double t = candidate.temperature[i];
double row_cn = 0, row_co = 0, row_cd = 0, row_kn = 0;
I scale_cn, scale_co, scale_cd, scale_kn;
t_one = Add(t_one, Magnitude(t));
d_square = Add(d_square, Mul(Magnitude(change[i]), Magnitude(change[i])));
o_square = Add(o_square, Mul(Magnitude(old[i]), Magnitude(old[i])));
for (auto j = s.c.RowOffsets()[i]; j < s.c.RowOffsets()[i + 1]; ++j) {
const auto col = s.c.ColumnIndices()[j];
const double c = s.c.Values()[j], k = s.k.Values()[j];
row_cn += c * candidate.temperature[col];
row_co += c * old[col];
row_cd += c * change[col];
row_kn += k * candidate.temperature[col];
scale_cn = Add(
scale_cn, Mul(Magnitude(c), Magnitude(candidate.temperature[col])));
scale_co = Add(scale_co, Mul(Magnitude(c), Magnitude(old[col])));
scale_cd = Add(scale_cd, Mul(Magnitude(c), Magnitude(change[col])));
scale_kn = Add(
scale_kn, Mul(Magnitude(k), Magnitude(candidate.temperature[col])));
const I nc = Mul(Mul(Magnitude(t), Magnitude(c)),
Magnitude(candidate.temperature[col]));
const I oc =
Mul(Mul(Magnitude(old[i]), Magnitude(c)), Magnitude(old[col]));
const I dc = Mul(Mul(Magnitude(change[i]), Magnitude(c)),
Magnitude(change[col]));
sc = Add(sc, Mul(I{.5}, Add(nc, Add(oc, dc))));
sc = Add(sc, Mul(I{delta}, Mul(Mul(Magnitude(t), Magnitude(k)),
Magnitude(candidate.temperature[col]))));
subscale =
Add(subscale,
Mul(Mul(Add(Magnitude(t), Magnitude(old[i])), Magnitude(c)),
Magnitude(change[col])));
const auto begin = s.c.ColumnIndices().begin() +
static_cast<std::ptrdiff_t>(s.c.RowOffsets()[col]);
const auto end = s.c.ColumnIndices().begin() +
static_cast<std::ptrdiff_t>(s.c.RowOffsets()[col + 1]);
const auto at = std::lower_bound(begin, end, i);
const double ct = (at != end && *at == i)
? s.c.Values()[static_cast<std::size_t>(
at - s.c.ColumnIndices().begin())]
: 0;
const I skew = c == ct ? I{0} : Abs(Sub(I{c}, I{ct}));
skew_square = Add(skew_square, Mul(skew, skew));
}
cn += t * row_cn;
co += old[i] * row_co;
cd += change[i] * row_cd;
kn += t * row_kn;
const double operations =
2 *
static_cast<double>(s.c.RowOffsets()[i + 1] - s.c.RowOffsets()[i]);
I c_under =
Add(Mul(Magnitude(t), Tiny(operations, scale_cn)),
Add(Mul(Magnitude(old[i]), Tiny(operations, scale_co)),
Mul(Magnitude(change[i]), Tiny(operations, scale_cd))));
c_under = Add(c_under, Tiny(6, Add(scale_cn, Add(scale_co, scale_cd))));
const I k_under =
Add(Mul(Magnitude(t), Tiny(operations, scale_kn)), Tiny(2, scale_kn));
quadratic_underflow =
Add(quadratic_underflow,
Add(Mul(I{.5}, c_under), Mul(I{delta}, k_under)));
}
I bc = Mul(Mul(Mul(I{delta}, t_one), beta_limit), denominator);
bc = Add(bc, Roundoff(6 * md + 20, sc));
// Frobenius bounds the requested spectral skew norm without dense storage.
if (!Zero(skew_square))
bc = Add(bc, Mul(I{.5}, Mul(thermal_internal::Sqrt(d_square),
Mul(thermal_internal::Sqrt(skew_square),
thermal_internal::Sqrt(o_square)))));
bc = Add(bc, Mul(Gamma(1), subscale));
bc = Add(bc, quadratic_underflow);
ev.dissipation_defect = std::abs(.5 * ((cn - co) + cd) + delta * kn);
ev.dissipation_bound = bc.upper;
if (!bc.IsFinite() || !std::isfinite(ev.dissipation_defect) ||
ev.dissipation_defect > bc.upper || !std::isfinite(cn) ||
!std::isfinite(co) || Sub(I{cn}, I{co}).lower > Mul(I{2}, bc).upper)
return Failure(
"Insulated backward Euler capacity-norm dissipation failed.");
}
return Status::Ok();
}
} // namespace
Result<ThermalStepCandidate> SolveThermalIncrement(
const ThermalSystem& s, const ThermalPartition& p, const Vector& old,
double delta, LinearSolver& solver, ThermalFactorization& cache) {
auto status = Validate(s, p, old, delta);
if (!status.IsOk()) return Result<ThermalStepCandidate>::Failure(status);
auto equations = Build(s, p, old, delta);
if (!equations.HasValue())
return Result<ThermalStepCandidate>::Failure(equations.GetStatus());
const auto n = p.free.size(), m = old.Size();
ThermalStepCandidate candidate{
Vector{m}, Vector{m}, Vector{p.constrained.size()}, Vector{m}, {}};
for (std::size_t i = 0; i < p.constrained.size(); ++i)
candidate.temperature[p.constrained[i]] = p.prescribed[i];
if (n) {
if (!cache.matrix || cache.k != &s.k || cache.c != &s.c ||
cache.solver != &solver || cache.free != p.free ||
cache.delta != delta) {
cache = ThermalFactorization{};
cache.matrix = equations.Value().a;
status = solver.Factorize(*cache.matrix);
if (!status.IsOk()) {
cache = ThermalFactorization{};
return Result<ThermalStepCandidate>::Failure(status);
}
cache.k = &s.k;
cache.c = &s.c;
cache.solver = &solver;
cache.free = p.free;
cache.delta = delta;
}
Vector free{n};
status = solver.Solve(equations.Value().b, free);
if (!status.IsOk()) return Result<ThermalStepCandidate>::Failure(status);
if (free.Size() != n || !Finite(free))
return Result<ThermalStepCandidate>::Failure(
Failure("Solver returned invalid temperatures."));
for (std::size_t i = 0; i < n; ++i)
candidate.temperature[p.free[i]] = free[i];
}
status = Check(s, p, old, delta, equations.Value(), candidate);
if (!status.IsOk()) return Result<ThermalStepCandidate>::Failure(status);
return Result<ThermalStepCandidate>::Success(std::move(candidate));
}
Status RunThermalHistory(const ThermalAssembly& assembly,
const DofManager& dofs, ThermalAnalysisState& state,
LinearSolver& solver, const ThermalRecovery& recovery,
std::vector<ThermalStepEvidence>* evidence) {
if (!dofs.ValidateInvariants().IsOk() ||
dofs.Profile() != DofProfile::kThermal ||
dofs.FullDofCount() != state.CommittedState().temperature.Size() ||
assembly.ElementOrder() != state.ElementOrder() || !recovery)
return Failure("History does not match the thermal operators and mapping.");
ThermalSystem system{assembly.Conductivity(), assembly.Capacity(),
assembly.HeatInput(), &assembly.ConductivityError(),
&assembly.CapacityError(), &assembly.HeatInputError()};
ThermalPartition partition{dofs.FreeDofs(), dofs.ConstrainedDofs(),
dofs.PrescribedValues()};
ThermalFactorization cache;
const std::uint64_t count = state.TimeGrid().Count();
for (std::uint64_t i =
static_cast<std::uint64_t>(state.AcceptedFrames().size());
i <= count; ++i) {
const auto frame = state.TimeGrid().Frame(static_cast<std::uint32_t>(i));
if (!frame.HasValue()) return frame.GetStatus();
auto candidate = SolveThermalIncrement(system, partition,
state.CommittedState().temperature,
frame.Value().delta, solver, cache);
if (!candidate.HasValue()) return candidate.GetStatus();
auto flux = recovery(candidate.Value().temperature);
if (!flux.HasValue()) return flux.GetStatus();
auto completed = state.MakeCompletedFrame(
static_cast<std::uint32_t>(i), std::move(candidate.Value().temperature),
std::move(candidate.Value().reaction), std::move(flux.Value()));
if (!completed.HasValue()) return completed.GetStatus();
const auto committed = state.CommitAcceptedFrame(completed.Value());
if (!committed.IsOk()) return committed;
if (evidence) evidence->push_back(candidate.Value().evidence);
}
return Status::Ok();
}
} // namespace fesa::thermal_transient_internal
@@ -0,0 +1,105 @@
#ifndef FESA_ANALYSIS_THERMAL_TRANSIENT_ALGEBRA_H_
#define FESA_ANALYSIS_THERMAL_TRANSIENT_ALGEBRA_H_
#include <functional>
#include <optional>
#include <vector>
#include "fesa/analysis/thermal_analysis_state.h"
#include "fesa/solvers/linear/linear_solver.h"
namespace fesa::thermal_transient_internal {
/// @brief References fixed operators for one algebra/history invocation.
/// @note All referenced objects outlive calls; optional errors bound
/// coefficients, not quadrature/model error. K and C use the same full CSR
/// pattern.
struct ThermalSystem {
const SparseMatrix& k;
const SparseMatrix& c;
const Vector& q;
const SparseMatrix* k_error = nullptr;
const SparseMatrix* c_error = nullptr;
const Vector* q_error = nullptr;
};
/// @brief Declares an explicit generic partition, separate from public BC
/// syntax.
struct ThermalPartition {
std::vector<std::size_t> free;
std::vector<std::size_t> constrained;
Vector prescribed{0U};
};
/// @brief Owns only the matrix backing the solver's current reusable
/// factorization.
struct ThermalFactorization {
std::optional<SparseMatrix> matrix;
const SparseMatrix* k = nullptr;
const SparseMatrix* c = nullptr;
const LinearSolver* solver = nullptr;
std::vector<std::size_t> free;
double delta = 0;
};
/// @brief Records internal scalar numerical evidence, never mandatory output
/// fields.
struct ThermalStepEvidence {
// Stored reduced system certificate; retain observed beta for forward
// budgets.
double beta_cert = 0;
double beta_limit = 0;
double free_residual_cert = 0;
double full_free_residual_cert = 0;
double full_beta_cert = 0;
// Stored-data arithmetic construction, separate from assembly E_C/E_K.
double operator_construction_error = 0;
double rhs_construction_error = 0;
double construction_identity_bound = 0;
double energy_change = 0;
double energy_defect = 0;
double energy_bound = 0;
bool dissipation_checked = false;
double dissipation_defect = 0;
double dissipation_bound = 0;
};
/// @brief Holds an unpublished finite/equilibrium-checked algebra candidate.
struct ThermalStepCandidate {
Vector temperature{0U};
Vector residual{0U};
Vector reaction{0U};
// Entrywise |full r| + evaluation error + E_C|dT|/delta + E_K|T| + E_Q.
// Forward verification evidence never relaxes solve acceptance.
Vector certified_residual{0U};
ThermalStepEvidence evidence;
};
/// @brief Solves/checks one actual-delta increment without modifying old state.
/// @note An empty free partition skips factorization and solve. Cache reuse is
/// restricted to identical operator addresses, partition, solver and actual
/// delta.
Result<ThermalStepCandidate> SolveThermalIncrement(
const ThermalSystem& system, const ThermalPartition& partition,
const Vector& old_temperature, double delta, LinearSolver& solver,
ThermalFactorization& factorization);
/// @brief Provides an internal recovery seam for the actual history commit
/// path.
using ThermalRecovery = std::function<Result<std::vector<ThermalElementFlux>>(
const Vector& temperature)>;
/// @brief Advances an existing raw/accepted history, preserving it on every
/// failure.
/// @note Recovery runs after algebra validation and before atomic commit.
/// Optional evidence contains successful increments only; no count-sized
/// preallocation.
/// @pre State, assembly and dofs were created from the same fixed model. The
/// assembly's original Domain remains alive and unmoved throughout this call.
Status RunThermalHistory(const ThermalAssembly& assembly,
const DofManager& dofs, ThermalAnalysisState& state,
LinearSolver& solver, const ThermalRecovery& recovery,
std::vector<ThermalStepEvidence>* evidence = nullptr);
} // namespace fesa::thermal_transient_internal
#endif // FESA_ANALYSIS_THERMAL_TRANSIENT_ALGEBRA_H_
@@ -0,0 +1,90 @@
#ifndef FESA_ANALYSIS_THERMAL_TRANSIENT_BOUNDS_H_
#define FESA_ANALYSIS_THERMAL_TRANSIENT_BOUNDS_H_
#include <algorithm>
#include <cmath>
#include <limits>
#include "elements/thermal_interval.h"
namespace fesa::thermal_transient_internal::bounds {
using I = thermal_internal::Interval;
/// @brief Recognizes proven zero, distinct from an interval containing zero.
inline bool Zero(I x) { return x.lower == 0 && x.upper == 0; }
/// @brief Encloses addition, retaining proven exact sums and zero identities.
inline I Add(I a, I b) {
if (Zero(a)) return b;
if (Zero(b)) return a;
if (a.lower == a.upper && b.lower == b.upper) {
const double sum = a.lower + b.lower;
const double bv = sum - a.lower, av = sum - bv;
const double error = (a.lower - av) + (b.lower - bv);
// TwoSum proves exact additions (including exact cancellation) without
// creating spurious construction uncertainty at prescribed stationary DOFs.
if (std::isfinite(sum) && std::isfinite(bv) && std::isfinite(av) &&
error == 0)
return I{sum};
}
return a + b;
}
/// @brief Recognizes singleton positive powers of two for exact scaling.
inline bool PowerOfTwo(I x, int& exponent) {
if (x.lower != x.upper || x.lower <= 0 || !std::isfinite(x.lower))
return false;
const double fraction = std::frexp(x.lower, &exponent);
--exponent;
return fraction == .5;
}
/// @brief Encloses products, including loss in subnormal power-of-two scaling.
inline I Mul(I a, I b) {
if (Zero(a) || Zero(b)) return I{0};
if (a.lower == a.upper && a.lower < 0) {
const I value = Mul(I{-a.lower}, b);
return {-value.upper, -value.lower};
}
if (b.lower == b.upper && b.lower < 0) {
const I value = Mul(a, I{-b.lower});
return {-value.upper, -value.lower};
}
int exponent = 0;
if (PowerOfTwo(a, exponent)) return thermal_internal::Scale(b, exponent);
if (PowerOfTwo(b, exponent)) return thermal_internal::Scale(a, exponent);
return a * b;
}
/// @brief Encloses division without discarding subnormal quotient uncertainty.
inline I Div(I a, I b) {
if (b.ContainsZero()) return a / b;
if (Zero(a)) return I{0};
int exponent = 0;
if (PowerOfTwo(b, exponent)) return thermal_internal::Scale(a, -exponent);
return a / b;
}
/// @brief Encloses the absolute value of a signed interval.
inline I Abs(I a) {
return {a.ContainsZero() ? 0 : std::min(std::abs(a.lower), std::abs(a.upper)),
std::max(std::abs(a.lower), std::abs(a.upper))};
}
/// @brief Changes interval sign exactly.
inline I Neg(I a) { return {-a.upper, -a.lower}; }
/// @brief Preserves exact cancellation through the addition certificate.
inline I Sub(I a, I b) { return Add(a, Neg(b)); }
/// @brief Lifts an exact binary64 magnitude into an interval.
inline I Magnitude(double a) { return I{std::abs(a)}; }
/// @brief Encloses the approved binary64 gamma operation-count bound.
inline I Gamma(double count) {
const I product = Mul(I{count}, I{0x1p-53});
return Div(product, Sub(I{1}, product));
}
/// @brief Bounds absolute underflow by one minimum subnormal per operation.
/// @note A proven zero expression performs no inexact operation.
inline I Tiny(double count, I scale) {
return Zero(scale)
? I{0}
: Mul(I{count}, I{std::numeric_limits<double>::denorm_min()});
}
/// @brief Combines relative gamma error with absolute underflow error.
inline I Roundoff(double count, I scale) {
return Add(Mul(Gamma(count), scale), Tiny(count, scale));
}
} // namespace fesa::thermal_transient_internal::bounds
#endif // FESA_ANALYSIS_THERMAL_TRANSIENT_BOUNDS_H_
@@ -0,0 +1,39 @@
#include "fesa/analysis/transient_heat_procedure.h"
#include <utility>
#include "thermal_transient_algebra.h"
namespace fesa {
TransientHeatProcedureExecutor::TransientHeatProcedureExecutor(
const ParallelFor& parallel_for, LinearSolver& linear_solver)
: parallel_for_{parallel_for}, linear_solver_{linear_solver} {}
Result<ThermalExecutionResult> TransientHeatProcedureExecutor::Execute(
const Domain& domain) const {
auto model = AnalysisModel::Create(domain);
if (!model.HasValue())
return Result<ThermalExecutionResult>::Failure(model.GetStatus());
auto dofs = DofManager::CreateThermal(model.Value());
if (!dofs.HasValue())
return Result<ThermalExecutionResult>::Failure(dofs.GetStatus());
auto assembly = AssembleThermal(model.Value(), dofs.Value(), parallel_for_);
if (!assembly.HasValue())
return Result<ThermalExecutionResult>::Failure(assembly.GetStatus());
auto state = ThermalAnalysisState::Create(model.Value(), dofs.Value(),
assembly.Value());
if (!state.HasValue())
return Result<ThermalExecutionResult>::Failure(state.GetStatus());
const Status completed = thermal_transient_internal::RunThermalHistory(
assembly.Value(), dofs.Value(), state.Value(), linear_solver_,
[&](const Vector& temperature) {
return assembly.Value().RecoverFlux(temperature, parallel_for_);
});
if (!completed.IsOk())
return Result<ThermalExecutionResult>::Failure(completed);
return Result<ThermalExecutionResult>::Success(
{std::move(state.Value()), domain.Warnings()});
}
} // namespace fesa
@@ -0,0 +1,472 @@
#include "fesa/analysis/transient_heat_procedure.h"
#include <gtest/gtest.h>
#include <cmath>
#include <cstring>
#include <functional>
#include <limits>
#include <sstream>
#include "analysis/thermal_transient_algebra.h"
#include "analysis/thermal_transient_bounds.h"
#include "fesa/solvers/linear/mkl_pardiso_solver.h"
namespace {
namespace ti = fesa::thermal_transient_internal;
std::string Messages(const fesa::Status& status) {
std::ostringstream out;
for (const auto& d : status.Diagnostics()) out << d.message << "\n";
return out.str();
}
fesa::ModelDefinition Plate(double initial = 0, double period = .9,
double dt = .3) {
fesa::ModelDefinition m{};
m.nodes = {{{"plate", 1, "1"}, {0, 0, 0}, {}},
{{"plate", 2, "2"}, {1, 0, 0}, {}},
{{"plate", 3, "3"}, {1, 1, 0}, {}},
{{"plate", 4, "4"}, {0, 1, 0}, {}}};
m.thermal_materials = {{{"plate", 1, "1"}, "heat", 1, 1, 1, {}}};
m.thermal_sections = {{{"plate", 1, "1"}, "section", 2, 0, {}}};
m.thermal_elements = {{{"plate", 1, "1"}, {{0, 1, 2, 3}}, 0, {}}};
for (fesa::EntityIndex i = 0; i < 4; ++i)
m.thermal_initial_conditions.push_back({i, initial, {}});
m.thermal_step = fesa::ThermalStepDefinition{"heat", dt, period, 100};
return m;
}
fesa::SparseMatrix TwoMatrix(double a, double b) {
return fesa::SparseMatrix::FromCoo(2, 2,
{{0, 0, a, 0, 0},
{0, 1, b, 0, 1},
{1, 0, b, 0, 2},
{1, 1, a, 0, 3}},
{{0, 2, 4}, {0, 1, 0, 1}})
.Value();
}
class CountingSolver : public fesa::LinearSolver {
public:
fesa::Status Factorize(const fesa::SparseMatrix& a) override {
++factorizations;
return solver.Factorize(a);
}
fesa::Status Solve(const fesa::Vector& b, fesa::Vector& x) const override {
++solves;
if (before_solve) before_solve(solves);
if (solves == fail_on)
return fesa::Status::Failure(fesa::FailureCategory::kSolver, {});
const auto status = solver.Solve(b, x);
if (status.IsOk() && solves == corrupt_on) x[0] = corrupt_value;
return status;
}
int factorizations = 0;
mutable int solves = 0;
int fail_on = 0, corrupt_on = 0;
double corrupt_value = 0;
std::function<void(int)> before_solve;
fesa::MklPardisoSolver solver;
};
std::vector<std::uint64_t> Snapshot(const fesa::ThermalAnalysisState& state) {
std::vector<std::uint64_t> result;
const auto scalar = [&](double x) {
std::uint64_t bits = 0;
std::memcpy(&bits, &x, sizeof(bits));
result.push_back(bits);
};
for (const auto& frame : state.AcceptedFrames()) {
result.insert(result.end(),
{frame.frame_index, frame.increment_index, frame.is_initial,
frame.completed.has_value()});
scalar(frame.time);
scalar(frame.time_increment);
for (const auto node : frame.node_order) result.push_back(node);
for (std::size_t i = 0; i < frame.temperature.Size(); ++i)
scalar(frame.temperature[i]);
if (frame.completed) {
for (const auto dof : frame.completed->reaction_dofs) {
result.push_back(dof.node);
result.push_back(dof.source_dof);
}
for (std::size_t i = 0; i < frame.completed->reaction_heat_rate.Size();
++i)
scalar(frame.completed->reaction_heat_rate[i]);
for (const auto e : frame.completed->element_order) result.push_back(e);
for (const auto& e : frame.completed->global_heat_flux)
for (const auto& q : e)
for (const auto x : q.Components()) scalar(x);
}
}
return result;
}
} // namespace
TEST(TransientHeatProcedure, RawConstrainedCapacityProducesSignedTwoDofJump) {
const auto c = TwoMatrix(2, 1), k = TwoMatrix(1, -1);
const fesa::Vector q{2};
ti::ThermalPartition p{{0}, {1}, fesa::Vector{1, 1}};
CountingSolver solver;
ti::ThermalFactorization cache;
auto first = ti::SolveThermalIncrement({k, c, q}, p, fesa::Vector{2}, .5,
solver, cache);
ASSERT_TRUE(first.HasValue()) << Messages(first.GetStatus());
EXPECT_DOUBLE_EQ(first.Value().temperature[0], -.2);
EXPECT_DOUBLE_EQ(first.Value().reaction[0], 4.8);
auto second = ti::SolveThermalIncrement(
{k, c, q}, p, first.Value().temperature, .5, solver, cache);
ASSERT_TRUE(second.HasValue()) << Messages(second.GetStatus());
EXPECT_NEAR(second.Value().temperature[0], .04,
16 * std::numeric_limits<double>::epsilon() * .04);
EXPECT_DOUBLE_EQ(second.Value().reaction[0], 1.44);
EXPECT_EQ(solver.factorizations, 1);
EXPECT_EQ(solver.solves, 2);
}
TEST(TransientHeatProcedure, AllConstrainedBypassesZeroSizeFactorization) {
const auto c = TwoMatrix(2, 1), k = TwoMatrix(1, -1);
const fesa::Vector q{2};
ti::ThermalPartition p{{}, {0, 1}, fesa::Vector{2, 1}};
CountingSolver solver;
ti::ThermalFactorization cache;
auto result = ti::SolveThermalIncrement({k, c, q}, p, fesa::Vector{2}, .5,
solver, cache);
ASSERT_TRUE(result.HasValue()) << Messages(result.GetStatus());
EXPECT_EQ(result.Value().reaction[0], 6);
EXPECT_EQ(result.Value().reaction[1], 6);
EXPECT_EQ(solver.factorizations, 0);
EXPECT_EQ(solver.solves, 0);
}
TEST(TransientHeatProcedure,
CancellationCannotUseFullSystemScaleToPassCertificate) {
const auto c = TwoMatrix(2, 1), k = TwoMatrix(1, -1);
const fesa::Vector q{2};
const double large = std::ldexp(1., 56);
ti::ThermalPartition p{{0}, {1}, fesa::Vector{1, 2 * large}};
fesa::Vector old{2};
old[0] = 1;
old[1] = large;
CountingSolver solver;
ti::ThermalFactorization cache;
auto valid = ti::SolveThermalIncrement({k, c, q}, p, old, .5, solver, cache);
ASSERT_TRUE(valid.HasValue()) << Messages(valid.GetStatus());
EXPECT_DOUBLE_EQ(valid.Value().temperature[0], .8);
// The stable old-minus-g RHS retains b=4. Inject the naive lost-b solution;
// it must fail, even though normalization by the huge full state could pass.
solver.corrupt_on = 2;
EXPECT_FALSE(ti::SolveThermalIncrement({k, c, q}, p, old, .5, solver, cache)
.HasValue());
}
TEST(TransientHeatProcedure,
ExactZeroAndUniformInsulatedStatesAndActualDeltaCache) {
struct Schedule {
double period, dt;
std::size_t steps;
int factors;
double last;
};
const Schedule schedules[] = {{.9, .3, 3, 1, .3},
{std::nextafter(.9, 1.), .3, 4, 2,
std::fma(-3., .3, std::nextafter(.9, 1.))},
{1., .3, 4, 2, std::fma(-3., .3, 1.)},
{.2, .3, 1, 1, .2}};
for (const auto schedule : schedules)
for (const double initial : {0., 2.}) {
auto domain =
fesa::Domain::Create(Plate(initial, schedule.period, schedule.dt));
ASSERT_TRUE(domain.HasValue()) << Messages(domain.GetStatus());
CountingSolver solver;
fesa::SerialParallelFor parallel;
auto result =
fesa::TransientHeatProcedureExecutor{parallel, solver}.Execute(
domain.Value());
ASSERT_TRUE(result.HasValue()) << Messages(result.GetStatus());
const auto& state = result.Value().state;
EXPECT_TRUE(state.IsComplete());
EXPECT_EQ(state.AcceptedFrames().size(), schedule.steps + 1);
EXPECT_EQ(state.CommittedState().time, schedule.period);
EXPECT_EQ(state.CommittedState().time_increment, schedule.last);
EXPECT_EQ(solver.factorizations, schedule.factors);
EXPECT_EQ(solver.solves, static_cast<int>(schedule.steps));
EXPECT_FALSE(state.AcceptedFrames()[0].completed.has_value());
for (const auto& frame : state.AcceptedFrames()) {
for (std::size_t i = 0; i < 20; ++i) {
if (initial == 0)
EXPECT_EQ(frame.temperature[i], 0);
else
EXPECT_NEAR(frame.temperature[i], initial,
4096 * 0x1p-53 * initial);
}
if (frame.completed)
EXPECT_EQ(frame.completed->reaction_heat_rate.Size(), 0U);
}
}
}
TEST(TransientHeatProcedure,
MklMatchesIndependentAffineDiscreteModeAndDissipation) {
// Q1 unit-square mass/stiffness give lambda=12 for (1,-1,-1,1).
// Constant thickness is its exact P2 null mode, independently of production.
auto definition = Plate(0, .25, .25);
const double mode[] = {1, -1, -1, 1};
for (fesa::EntityIndex i = 0; i < 4; ++i)
definition.thermal_initial_conditions[i] = {i, mode[i], {}};
auto domain = fesa::Domain::Create(std::move(definition));
ASSERT_TRUE(domain.HasValue());
auto model = fesa::AnalysisModel::Create(domain.Value());
ASSERT_TRUE(model.HasValue());
auto dofs = fesa::DofManager::CreateThermal(model.Value());
ASSERT_TRUE(dofs.HasValue());
fesa::SerialParallelFor parallel;
auto assembly = fesa::AssembleThermal(model.Value(), dofs.Value(), parallel);
ASSERT_TRUE(assembly.HasValue());
auto state = fesa::ThermalAnalysisState::Create(model.Value(), dofs.Value(),
assembly.Value());
ASSERT_TRUE(state.HasValue());
CountingSolver solver;
ti::ThermalFactorization cache;
const auto& a = assembly.Value();
auto result = ti::SolveThermalIncrement(
{a.Conductivity(), a.Capacity(), a.HeatInput(), &a.ConductivityError(),
&a.CapacityError(), &a.HeatInputError()},
{dofs.Value().FreeDofs(), {}, fesa::Vector{0}},
state.Value().CommittedState().temperature, .25, solver, cache);
ASSERT_TRUE(result.HasValue()) << Messages(result.GetStatus());
const auto& candidate = result.Value();
// lambda_min(Msurface)>=1/36 and lambda_min(Z0)>=1/30 by exact
// tensor eigenvalues/Gershgorin; h/2=1. Thus ||A^-1||inf<=sqrt(20)*1080*dt.
double residual = 0;
for (std::size_t i = 0; i < 20; ++i)
residual = std::max(residual, candidate.certified_residual[i]);
const double bound = std::sqrt(20.) * 1080 * .25 * residual;
for (std::size_t i = 0; i < 20; ++i)
EXPECT_NEAR(candidate.temperature[i], mode[i / 5] / 4, bound);
EXPECT_LE(candidate.evidence.beta_cert, candidate.evidence.beta_limit);
EXPECT_LE(candidate.evidence.energy_defect, candidate.evidence.energy_bound);
EXPECT_TRUE(candidate.evidence.dissipation_checked);
EXPECT_LE(candidate.evidence.dissipation_defect,
candidate.evidence.dissipation_bound);
auto flux = a.RecoverFlux(candidate.temperature, parallel);
ASSERT_TRUE(flux.HasValue());
// q=(.5,0,0); each exact B row has absolute sum bounded by 16 for this h=2
// square.
for (const auto& q : flux.Value()[0]) {
EXPECT_NEAR(q.X(), .5, 16 * bound + 4096 * 0x1p-53 * .5);
EXPECT_NEAR(q.Y(), 0, 16 * bound);
EXPECT_NEAR(q.Z(), 0, 16 * bound);
}
}
TEST(TransientHeatProcedure,
LaterSolveRecoveryAndPayloadFailuresPreserveEveryAcceptedBit) {
for (int failure = 0; failure < 5; ++failure) {
auto definition = Plate(2, .6, .3);
definition.thermal_model_boundaries = {{0, 11, 1, {}}};
auto domain = fesa::Domain::Create(std::move(definition));
ASSERT_TRUE(domain.HasValue());
auto model = fesa::AnalysisModel::Create(domain.Value());
ASSERT_TRUE(model.HasValue());
auto dofs = fesa::DofManager::CreateThermal(model.Value());
ASSERT_TRUE(dofs.HasValue());
fesa::SerialParallelFor parallel;
auto assembly =
fesa::AssembleThermal(model.Value(), dofs.Value(), parallel);
ASSERT_TRUE(assembly.HasValue());
auto state = fesa::ThermalAnalysisState::Create(model.Value(), dofs.Value(),
assembly.Value());
ASSERT_TRUE(state.HasValue());
CountingSolver solver;
if (failure == 0) solver.fail_on = 2;
if (failure >= 3) {
solver.corrupt_on = 2;
solver.corrupt_value =
failure == 3 ? std::numeric_limits<double>::quiet_NaN() : 1e20;
}
std::vector<std::uint64_t> before;
solver.before_solve = [&](int count) {
if (count == 2) before = Snapshot(state.Value());
};
int recoveries = 0;
std::vector<ti::ThermalStepEvidence> evidence;
const auto status = ti::RunThermalHistory(
assembly.Value(), dofs.Value(), state.Value(), solver,
[&](const fesa::Vector& temperature) {
++recoveries;
if (recoveries == 2 && failure == 1)
return fesa::Result<std::vector<fesa::ThermalElementFlux>>::Failure(
fesa::Status::Failure(fesa::FailureCategory::kSolver, {}));
auto flux = assembly.Value().RecoverFlux(temperature, parallel);
if (recoveries == 2 && failure == 2)
flux.Value()[0][0] =
fesa::Vector3{std::numeric_limits<double>::infinity(), 0, 0};
return flux;
},
&evidence);
EXPECT_FALSE(status.IsOk());
ASSERT_EQ(state.Value().AcceptedFrames().size(), 2U) << Messages(status);
EXPECT_FALSE(state.Value().IsComplete());
EXPECT_EQ(evidence.size(), 1U);
EXPECT_EQ(Snapshot(state.Value()), before);
EXPECT_EQ(state.Value().AcceptedFrames()[0].temperature[0], 2);
EXPECT_EQ(state.Value().CommittedState().temperature[0], 1);
EXPECT_EQ(solver.factorizations, 1);
}
}
TEST(TransientHeatProcedure,
SignedPowerConstrainedReactionAndDisconnectedComponentBalance) {
auto definition = Plate(0, .5, .25);
// A separate translated patch keeps an independent no-BC heat balance.
for (fesa::EntityIndex i = 0; i < 4; ++i) {
auto node = definition.nodes[i];
node.source_id = {"other", i + 1, std::to_string(i + 1)};
node.coordinates[0] += 3;
definition.nodes.push_back(node);
definition.thermal_initial_conditions.push_back({i + 4, 0, {}});
}
definition.thermal_elements.push_back(
{{"other", 2, "2"}, {{4, 5, 6, 7}}, 0, {}});
definition.thermal_model_boundaries = {{0, 11, 0, {}}};
definition.thermal_step->loads = {
{0, 11, 3, {}}, {1, 15, -3, {}}, {4, 11, 5, {}}, {6, 15, -5, {}}};
auto domain = fesa::Domain::Create(std::move(definition));
ASSERT_TRUE(domain.HasValue()) << Messages(domain.GetStatus());
auto model = fesa::AnalysisModel::Create(domain.Value());
ASSERT_TRUE(model.HasValue());
auto dofs = fesa::DofManager::CreateThermal(model.Value());
ASSERT_TRUE(dofs.HasValue());
fesa::SerialParallelFor parallel;
auto assembly = fesa::AssembleThermal(model.Value(), dofs.Value(), parallel);
ASSERT_TRUE(assembly.HasValue());
auto state = fesa::ThermalAnalysisState::Create(model.Value(), dofs.Value(),
assembly.Value());
ASSERT_TRUE(state.HasValue());
CountingSolver solver;
std::vector<ti::ThermalStepEvidence> evidence;
const auto status = ti::RunThermalHistory(
assembly.Value(), dofs.Value(), state.Value(), solver,
[&](const fesa::Vector& t) {
return assembly.Value().RecoverFlux(t, parallel);
},
&evidence);
ASSERT_TRUE(status.IsOk()) << Messages(status);
ASSERT_EQ(evidence.size(), 2U);
const double weight[] = {1. / 24, 1. / 6, 1. / 12, 1. / 6, 1. / 24};
for (std::size_t step = 1; step < 3; ++step) {
const auto& frame = state.Value().AcceptedFrames()[step];
const auto& old = state.Value().AcceptedFrames()[step - 1];
double balance[2] = {0, 0}, scale[2] = {0, 0};
for (std::size_t i = 0; i < 40; ++i) {
const double term =
weight[i % 5] * (frame.temperature[i] - old.temperature[i]);
balance[i / 20] += term;
scale[i / 20] += std::abs(term);
}
const double reaction = frame.completed->reaction_heat_rate[0];
EXPECT_NEAR(balance[0], .25 * reaction,
evidence[step - 1].energy_bound + 4096 * 0x1p-53 * scale[0]);
EXPECT_NEAR(balance[1], 0,
evidence[step - 1].energy_bound + 4096 * 0x1p-53 * scale[1]);
EXPECT_FALSE(evidence[step - 1].dissipation_checked);
}
// Adding a constrained power changes only its full-residual reaction by -P.
auto unloaded = Plate(0, .5, .25);
unloaded.thermal_model_boundaries = {{0, 11, 0, {}}};
unloaded.thermal_step->loads = {{1, 15, -3, {}}};
auto other_domain = fesa::Domain::Create(std::move(unloaded));
ASSERT_TRUE(other_domain.HasValue());
CountingSolver other_solver;
auto other =
fesa::TransientHeatProcedureExecutor{parallel, other_solver}.Execute(
other_domain.Value());
ASSERT_TRUE(other.HasValue());
for (std::size_t step = 1; step < 3; ++step) {
const auto& first = state.Value().AcceptedFrames()[step];
const auto& second = other.Value().state.AcceptedFrames()[step];
for (std::size_t i = 0; i < 20; ++i)
EXPECT_NEAR(first.temperature[i], second.temperature[i],
4096 * 0x1p-53 * std::abs(second.temperature[i]));
EXPECT_NEAR(first.completed->reaction_heat_rate[0] -
second.completed->reaction_heat_rate[0],
-3., 4096 * 0x1p-53 * 3);
}
}
TEST(TransientHeatProcedure,
ConstructionEnclosuresRetainExactIdentitiesAndSubnormalLoss) {
namespace b = ti::bounds;
const double tiny = std::numeric_limits<double>::denorm_min();
const auto exact = [&](b::I value, double expected) {
EXPECT_EQ(value.lower, expected);
EXPECT_EQ(value.upper, expected);
};
exact(b::Add(b::I{0x1p56}, b::I{-0x1p56}), 0);
exact(b::Add(b::I{0x1p-1022}, b::I{-std::nextafter(0x1p-1022, 0.)}), tiny);
exact(b::Div(b::I{2}, b::I{.5}), 4);
exact(b::Mul(b::I{-1}, b::I{-0x1p-100}), 0x1p-100);
exact(b::Mul(b::I{0x1p-500}, b::I{0x1p400}), 0x1p-100);
exact(b::Add(b::I{-.8}, b::I{1}), 0x1.9999999999998p-3);
// Exact rational targets lie strictly between the listed adjacent binary64
// numbers: 1+2^-53 and 2^-1075. Neither may become a singleton rounded value.
const auto midpoint = b::Add(b::I{1}, b::I{0x1p-53});
EXPECT_LE(midpoint.lower, 1);
EXPECT_GE(midpoint.upper, std::nextafter(1., 2.));
for (const auto half :
{b::Mul(b::I{tiny}, b::I{.5}), b::Div(b::I{tiny}, b::I{2})}) {
EXPECT_LE(half.lower, 0);
EXPECT_GE(half.upper, tiny);
EXPECT_NE(half.lower, half.upper);
}
EXPECT_FALSE(
b::Mul(b::I{std::numeric_limits<double>::max()}, b::I{2}).IsFinite());
exact(b::Roundoff(80, b::I{0}), 0);
EXPECT_GT(b::Roundoff(80, b::I{tiny}).upper, 0);
}
TEST(TransientHeatProcedure,
GenericAlgebraRejectsInvalidInputsAndUncertifiedSubnormalSolve) {
const auto c = TwoMatrix(2, 1), k = TwoMatrix(1, -1);
const fesa::Vector q{2};
const ti::ThermalPartition p{{0}, {1}, fesa::Vector{1, 1}};
CountingSolver solver;
ti::ThermalFactorization cache;
for (const double delta : {0., -1., std::numeric_limits<double>::infinity(),
std::numeric_limits<double>::quiet_NaN()})
EXPECT_FALSE(ti::SolveThermalIncrement({k, c, q}, p, fesa::Vector{2}, delta,
solver, cache)
.HasValue());
EXPECT_FALSE(ti::SolveThermalIncrement({k, c, q},
{{0}, {0}, fesa::Vector{1, 1}},
fesa::Vector{2}, .5, solver, cache)
.HasValue());
EXPECT_FALSE(ti::SolveThermalIncrement({k, c, q}, p, fesa::Vector{1}, .5,
solver, cache)
.HasValue());
auto invalid = TwoMatrix(2, -1);
EXPECT_FALSE(ti::SolveThermalIncrement({invalid, c, q}, p, fesa::Vector{2},
.5, solver, cache)
.HasValue());
ti::ThermalPartition small{
{0}, {1}, fesa::Vector{1, std::numeric_limits<double>::denorm_min()}};
EXPECT_FALSE(ti::SolveThermalIncrement({k, c, q}, small, fesa::Vector{2}, .5,
solver, cache)
.HasValue());
}
TEST(TransientHeatProcedure,
SerialAndTbbExecutionsHaveIdenticalAcceptedHistories) {
auto definition = Plate(2, .6, .3);
definition.thermal_step->loads = {{0, 11, 3, {}}, {2, 15, -3, {}}};
definition.thermal_model_boundaries = {{1, 15, -1, {}}};
auto domain = fesa::Domain::Create(std::move(definition));
ASSERT_TRUE(domain.HasValue());
fesa::SerialParallelFor serial;
fesa::TbbParallelFor parallel;
CountingSolver a, b;
auto left =
fesa::TransientHeatProcedureExecutor{serial, a}.Execute(domain.Value());
auto right =
fesa::TransientHeatProcedureExecutor{parallel, b}.Execute(domain.Value());
ASSERT_TRUE(left.HasValue()) << Messages(left.GetStatus());
ASSERT_TRUE(right.HasValue()) << Messages(right.GetStatus());
EXPECT_EQ(Snapshot(left.Value().state), Snapshot(right.Value().state));
}