feat(thermal): execute certified backward Euler histories

This commit is contained in:
KOKO\Mimi
2026-09-29 21:35:21 +09:00
parent 983e2febda
commit ef8ec4732a
6 changed files with 1248 additions and 0 deletions
@@ -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