feat(thermal): certify shell geometry and local heat operators
This commit is contained in:
@@ -13,8 +13,8 @@ TEST(ThermalTimeGrid, SingleStepUsesPeriodAsActualIncrement) {
|
||||
}
|
||||
|
||||
TEST(ThermalTimeGrid, PreservesGenuineRemainderOutsideInputCells) {
|
||||
const auto grid = fesa::ThermalTimeGrid::Create(
|
||||
std::nextafter(.9, 1.0), .3, 4U);
|
||||
const auto grid =
|
||||
fesa::ThermalTimeGrid::Create(std::nextafter(.9, 1.0), .3, 4U);
|
||||
ASSERT_TRUE(grid.HasValue());
|
||||
EXPECT_EQ(grid.Value().Count(), 4U);
|
||||
EXPECT_EQ(grid.Value().Delta(4U), 1.6653345369377348e-16);
|
||||
@@ -41,13 +41,14 @@ TEST(ThermalTimeGrid, DecimalCellsCounterexampleAndScale) {
|
||||
EXPECT_EQ(grid.Value().Delta(3U), delta);
|
||||
EXPECT_EQ(grid.Value().Time(3U), period);
|
||||
}
|
||||
const auto old_counterexample = fesa::ThermalTimeGrid::Create(
|
||||
5.351455173733616, .17262758624947144, 31U);
|
||||
const auto old_counterexample =
|
||||
fesa::ThermalTimeGrid::Create(5.351455173733616, .17262758624947144, 31U);
|
||||
ASSERT_TRUE(old_counterexample.HasValue());
|
||||
EXPECT_EQ(old_counterexample.Value().Count(), 31U);
|
||||
EXPECT_EQ(old_counterexample.Value().Delta(31U), .17262758624947144);
|
||||
EXPECT_FALSE(fesa::ThermalTimeGrid::Create(
|
||||
5.351455173733616, .17262758624947144, 30U).HasValue());
|
||||
EXPECT_FALSE(
|
||||
fesa::ThermalTimeGrid::Create(5.351455173733616, .17262758624947144, 30U)
|
||||
.HasValue());
|
||||
}
|
||||
|
||||
TEST(ThermalTimeGrid, BinadeLimitsAndMaximumFinitePreserveOriginalCells) {
|
||||
@@ -56,13 +57,16 @@ TEST(ThermalTimeGrid, BinadeLimitsAndMaximumFinitePreserveOriginalCells) {
|
||||
ASSERT_TRUE(grid.HasValue());
|
||||
EXPECT_EQ(grid.Value().Count(), 3U);
|
||||
EXPECT_EQ(grid.Value().Delta(3U), max / 3);
|
||||
const auto upper = fesa::ThermalTimeGrid::Create(4294967295., 1., 4294967295U);
|
||||
const auto upper =
|
||||
fesa::ThermalTimeGrid::Create(4294967295., 1., 4294967295U);
|
||||
ASSERT_TRUE(upper.HasValue());
|
||||
EXPECT_EQ(upper.Value().Count(), 4294967295U);
|
||||
EXPECT_TRUE(upper.Value().Frame(4294967295U).HasValue());
|
||||
EXPECT_FALSE(fesa::ThermalTimeGrid::Create(4294967296., 1., 4294967295U).HasValue());
|
||||
EXPECT_FALSE(fesa::ThermalTimeGrid::Create(max,
|
||||
std::numeric_limits<double>::denorm_min(), 4294967295U).HasValue());
|
||||
EXPECT_FALSE(
|
||||
fesa::ThermalTimeGrid::Create(4294967296., 1., 4294967295U).HasValue());
|
||||
EXPECT_FALSE(fesa::ThermalTimeGrid::Create(
|
||||
max, std::numeric_limits<double>::denorm_min(), 4294967295U)
|
||||
.HasValue());
|
||||
}
|
||||
|
||||
TEST(ThermalTimeGrid, ShortenedIncrementAndCheckedFrames) {
|
||||
@@ -83,7 +87,8 @@ TEST(ThermalTimeGrid, ShortenedIncrementAndCheckedFrames) {
|
||||
EXPECT_EQ(decimals.Value().Delta(10U), .1);
|
||||
const double below = std::nextafter(.9, 0.);
|
||||
const auto close = fesa::ThermalTimeGrid::Create(below, .3, 3U);
|
||||
const auto short_last = fesa::ThermalTimeGrid::Create(std::nextafter(below, 0.), .3, 3U);
|
||||
const auto short_last =
|
||||
fesa::ThermalTimeGrid::Create(std::nextafter(below, 0.), .3, 3U);
|
||||
ASSERT_TRUE(close.HasValue());
|
||||
ASSERT_TRUE(short_last.HasValue());
|
||||
EXPECT_EQ(close.Value().Delta(3U), .3);
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
#include "fesa/elements/thermal_shell.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <numeric>
|
||||
|
||||
namespace {
|
||||
using fesa::Matrix;
|
||||
using fesa::Vector3;
|
||||
using Patch = std::array<Vector3, 4>;
|
||||
const Patch kSquare{{{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}}};
|
||||
|
||||
double Gamma(int operations) {
|
||||
const double product = operations * std::ldexp(1., -53);
|
||||
return product / (1 - product);
|
||||
}
|
||||
double Norm(const Matrix& matrix) {
|
||||
double norm = 0;
|
||||
for (std::size_t i = 0; i < matrix.Rows(); ++i)
|
||||
for (std::size_t j = 0; j < matrix.Columns(); ++j)
|
||||
norm = std::hypot(norm, matrix(i, j));
|
||||
return norm;
|
||||
}
|
||||
double Difference(const Matrix& a, const Matrix& b) {
|
||||
double norm = 0;
|
||||
for (std::size_t i = 0; i < a.Rows(); ++i)
|
||||
for (std::size_t j = 0; j < a.Columns(); ++j)
|
||||
norm = std::hypot(norm, a(i, j) - b(i, j));
|
||||
return norm;
|
||||
}
|
||||
fesa::Result<fesa::ThermalShell> Kernel(const Patch& patch = kSquare,
|
||||
double h = 2, double k = 1) {
|
||||
return fesa::ThermalShell::Create({"patch", 19, "0019"}, {{7, 2, 9, 4}},
|
||||
patch, k, 1, 1, h, {"kernel.inp", 42});
|
||||
}
|
||||
struct Oracle {
|
||||
Matrix c{20, 20};
|
||||
Matrix k{20, 20};
|
||||
Matrix pc{20, 20};
|
||||
Matrix pk{20, 20};
|
||||
};
|
||||
|
||||
// Independently integrated exact rational unit-square matrices, h variable.
|
||||
// The signed products are kept separate from their absolute contribution sums.
|
||||
Oracle Rational(double h) {
|
||||
constexpr int mass[4][4] = {
|
||||
{4, 2, 1, 2}, {2, 4, 2, 1}, {1, 2, 4, 2}, {2, 1, 2, 4}};
|
||||
constexpr int stiffness[4][4] = {
|
||||
{4, -1, -2, -1}, {-1, 4, -1, -2}, {-2, -1, 4, -1}, {-1, -2, -1, 4}};
|
||||
constexpr int z0[5][5] = {{4, 2, -1, 0, 0},
|
||||
{2, 16, 2, 0, 0},
|
||||
{-1, 2, 8, 2, -1},
|
||||
{0, 0, 2, 16, 2},
|
||||
{0, 0, -1, 2, 4}};
|
||||
constexpr int z1[5][5] = {{7, -8, 1, 0, 0},
|
||||
{-8, 16, -8, 0, 0},
|
||||
{1, -8, 14, -8, 1},
|
||||
{0, 0, -8, 16, -8},
|
||||
{0, 0, 1, -8, 7}};
|
||||
Oracle result;
|
||||
for (std::size_t p = 0; p < 20; ++p)
|
||||
for (std::size_t q = 0; q < 20; ++q) {
|
||||
const auto i = p / 5, j = q / 5, a = p % 5, b = q % 5;
|
||||
result.c(p, q) = h * mass[i][j] * z0[a][b] / 2160.;
|
||||
const double tangential = h * stiffness[i][j] * z0[a][b] / 360.;
|
||||
const double normal = mass[i][j] * z1[a][b] / (54. * h);
|
||||
result.k(p, q) = tangential + normal;
|
||||
result.pc(p, q) = std::abs(result.c(p, q));
|
||||
const double sx[4] = {-1, 1, 1, -1}, sy[4] = {-1, -1, 1, 1};
|
||||
const double ps = (sx[i] == sx[j] ? 1. / 3 : 1. / 6) +
|
||||
(sy[i] == sy[j] ? 1. / 3 : 1. / 6);
|
||||
result.pk(p, q) = h * ps * std::abs(z0[a][b]) / 60 + std::abs(normal);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Independent 54-point B^T B/H^T H oracle: no exact-Z or production helper.
|
||||
Oracle Direct54(const Patch& coordinates, double h) {
|
||||
Oracle result;
|
||||
const double g = std::sqrt(.6);
|
||||
const std::array<double, 3> gauss{{-g, 0, g}},
|
||||
weight{{5. / 9, 8. / 9, 5. / 9}};
|
||||
constexpr double sx[4] = {-1, 1, 1, -1}, sy[4] = {-1, -1, 1, 1};
|
||||
for (std::size_t ix = 0; ix < 3; ++ix)
|
||||
for (std::size_t iy = 0; iy < 3; ++iy) {
|
||||
std::array<double, 4> n{}, dx{}, dy{};
|
||||
Vector3 a1, a2;
|
||||
for (std::size_t i = 0; i < 4; ++i) {
|
||||
n[i] = (1 + sx[i] * gauss[ix]) * (1 + sy[i] * gauss[iy]) / 4;
|
||||
dx[i] = sx[i] * (1 + sy[i] * gauss[iy]) / 4;
|
||||
dy[i] = sy[i] * (1 + sx[i] * gauss[ix]) / 4;
|
||||
a1 = a1 + dx[i] * coordinates[i];
|
||||
a2 = a2 + dy[i] * coordinates[i];
|
||||
}
|
||||
const double aa = a1.Dot(a1), bb = a2.Dot(a2), ab = a1.Dot(a2);
|
||||
const double det = aa * bb - ab * ab;
|
||||
const Vector3 normal = a1.Cross(a2) / std::sqrt(det);
|
||||
const Vector3 reciprocal1 = (bb * a1 - ab * a2) / det;
|
||||
const Vector3 reciprocal2 = (aa * a2 - ab * a1) / det;
|
||||
for (std::size_t segment = 0; segment < 2; ++segment)
|
||||
for (std::size_t iz = 0; iz < 3; ++iz) {
|
||||
const double s = gauss[iz];
|
||||
const std::array<double, 3> l{
|
||||
{s * (s - 1) / 2, 1 - s * s, s * (s + 1) / 2}};
|
||||
const std::array<double, 3> dl{{2 * s - 1, -4 * s, 2 * s + 1}};
|
||||
std::array<double, 20> shape{};
|
||||
std::array<Vector3, 20> gradient{};
|
||||
const double w =
|
||||
weight[ix] * weight[iy] * weight[iz] * std::sqrt(det) * h / 4;
|
||||
for (std::size_t i = 0; i < 4; ++i)
|
||||
for (std::size_t a = 0; a < 3; ++a) {
|
||||
const auto p = 5 * i + 2 * segment + a;
|
||||
shape[p] = n[i] * l[a];
|
||||
gradient[p] = l[a] * (dx[i] * reciprocal1 + dy[i] * reciprocal2) +
|
||||
(2 * n[i] * dl[a] / h) * normal;
|
||||
}
|
||||
for (std::size_t p = 0; p < 20; ++p)
|
||||
for (std::size_t q = 0; q < 20; ++q) {
|
||||
const double c = shape[p] * shape[q] * w;
|
||||
const double k = gradient[p].Dot(gradient[q]) * w;
|
||||
result.c(p, q) += c;
|
||||
result.k(p, q) += k;
|
||||
result.pc(p, q) += std::abs(c);
|
||||
// Component products, not absolute signed dot products.
|
||||
for (std::size_t axis = 0; axis < 3; ++axis)
|
||||
result.pk(p, q) +=
|
||||
std::abs(gradient[p][axis] * gradient[q][axis] * w);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Direct recovery operator and absolute propagation scale, independent of the
|
||||
// production cache. Rows preserve the two one-sided center derivatives.
|
||||
struct FluxOracle {
|
||||
std::array<Vector3, 24> value{};
|
||||
std::array<double, 24> scale{};
|
||||
};
|
||||
FluxOracle Flux(const Patch& patch, double h, const std::array<double, 20>& t) {
|
||||
FluxOracle result;
|
||||
const double g = 1 / std::sqrt(3.);
|
||||
const double sx[4] = {-1, 1, 1, -1}, sy[4] = {-1, -1, 1, 1};
|
||||
for (std::size_t ip = 0; ip < 4; ++ip) {
|
||||
std::array<double, 4> n{}, dx{}, dy{};
|
||||
Vector3 a1, a2;
|
||||
for (std::size_t i = 0; i < 4; ++i) {
|
||||
n[i] = (1 + sx[i] * sx[ip] * g) * (1 + sy[i] * sy[ip] * g) / 4;
|
||||
dx[i] = sx[i] * (1 + sy[i] * sy[ip] * g) / 4;
|
||||
dy[i] = sy[i] * (1 + sx[i] * sx[ip] * g) / 4;
|
||||
a1 = a1 + dx[i] * patch[i];
|
||||
a2 = a2 + dy[i] * patch[i];
|
||||
}
|
||||
const double aa = a1.Dot(a1), bb = a2.Dot(a2), ab = a1.Dot(a2),
|
||||
det = aa * bb - ab * ab;
|
||||
const Vector3 normal = a1.Cross(a2) / std::sqrt(det);
|
||||
const Vector3 r1 = (bb * a1 - ab * a2) / det,
|
||||
r2 = (aa * a2 - ab * a1) / det;
|
||||
for (std::size_t trace = 0; trace < 6; ++trace) {
|
||||
const double x = static_cast<double>(trace % 3) - 1;
|
||||
const double l[3] = {x * (x - 1) / 2, 1 - x * x, x * (x + 1) / 2};
|
||||
const double dl[3] = {2 * x - 1, -4 * x, 2 * x + 1};
|
||||
const auto row = 6 * ip + trace;
|
||||
for (std::size_t i = 0; i < 4; ++i)
|
||||
for (std::size_t a = 0; a < 3; ++a) {
|
||||
const auto p = 5 * i + (trace < 3 ? 0 : 2) + a;
|
||||
const Vector3 first = (l[a] * dx[i]) * r1;
|
||||
const Vector3 second = (l[a] * dy[i]) * r2;
|
||||
const Vector3 third = (2 * n[i] * dl[a] / h) * normal;
|
||||
result.value[row] =
|
||||
result.value[row] - t[p] * (first + second + third);
|
||||
result.scale[row] +=
|
||||
std::abs(t[p]) * (first.Norm() + second.Norm() + third.Norm());
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Jacobi orthogonal diagonalization plus a posteriori residual and
|
||||
// orthogonality budget. No guessed eigenvalue epsilon is used for the rank
|
||||
// decision.
|
||||
std::pair<double, double> EigenExtrema(const Matrix& original,
|
||||
double& backward) {
|
||||
Matrix a = original;
|
||||
const auto n = a.Rows();
|
||||
Matrix v{n, n};
|
||||
for (std::size_t i = 0; i < n; ++i) v(i, i) = 1;
|
||||
for (int sweep = 0; sweep < 100; ++sweep) {
|
||||
for (std::size_t p = 0; p < n; ++p)
|
||||
for (std::size_t q = p + 1; q < n; ++q) {
|
||||
if (a(p, q) == 0) continue;
|
||||
const double tau = (a(q, q) - a(p, p)) / (2 * a(p, q));
|
||||
const double t =
|
||||
std::copysign(1., tau) / (std::abs(tau) + std::hypot(1., tau));
|
||||
const double c = 1 / std::hypot(1., t), s = t * c;
|
||||
const double off = a(p, q);
|
||||
a(p, p) -= t * off;
|
||||
a(q, q) += t * off;
|
||||
a(p, q) = a(q, p) = 0;
|
||||
for (std::size_t j = 0; j < n; ++j) {
|
||||
if (j != p && j != q) {
|
||||
const double x = a(j, p), y = a(j, q);
|
||||
a(j, p) = a(p, j) = c * x - s * y;
|
||||
a(j, q) = a(q, j) = s * x + c * y;
|
||||
}
|
||||
const double x = v(j, p), y = v(j, q);
|
||||
v(j, p) = c * x - s * y;
|
||||
v(j, q) = s * x + c * y;
|
||||
}
|
||||
}
|
||||
}
|
||||
double residual = 0, orthogonality = 0;
|
||||
for (std::size_t i = 0; i < n; ++i)
|
||||
for (std::size_t j = 0; j < n; ++j) {
|
||||
double av = 0, vv = 0, absolute = 0;
|
||||
for (std::size_t k = 0; k < n; ++k) {
|
||||
av += original(i, k) * v(k, j);
|
||||
absolute += std::abs(original(i, k) * v(k, j));
|
||||
vv += v(k, i) * v(k, j);
|
||||
}
|
||||
residual = std::hypot(residual,
|
||||
std::abs(av - v(i, j) * a(j, j)) +
|
||||
Gamma(2 * static_cast<int>(n) + 2) *
|
||||
(absolute + std::abs(v(i, j) * a(j, j))));
|
||||
orthogonality =
|
||||
std::hypot(orthogonality, std::abs(vv - (i == j ? 1. : 0.)) +
|
||||
Gamma(2 * static_cast<int>(n) + 2));
|
||||
}
|
||||
EXPECT_LT(orthogonality, 1.);
|
||||
backward =
|
||||
(residual + 2 * Norm(original) * orthogonality) / (1 - orthogonality);
|
||||
double low = a(0, 0), high = a(0, 0);
|
||||
for (std::size_t i = 1; i < n; ++i) {
|
||||
low = std::min(low, a(i, i));
|
||||
high = std::max(high, a(i, i));
|
||||
}
|
||||
return {low, high};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(ThermalShell, RationalMatricesSymmetryAndConstantModeUseFixedBudgets) {
|
||||
const auto created = Kernel();
|
||||
ASSERT_TRUE(created.HasValue());
|
||||
const auto& kernel = created.Value();
|
||||
const auto exact = Rational(2);
|
||||
const double ec = Gamma(4096) * Norm(exact.pc),
|
||||
ek = Gamma(4096) * Norm(exact.pk);
|
||||
EXPECT_LE(Difference(kernel.Capacity(), exact.c), ec);
|
||||
EXPECT_LE(Difference(kernel.Conductivity(), exact.k), ek);
|
||||
double cs = 0, ks = 0, null = 0, total = 0;
|
||||
for (std::size_t i = 0; i < 20; ++i) {
|
||||
double row = 0;
|
||||
for (std::size_t j = 0; j < 20; ++j) {
|
||||
cs = std::hypot(cs, kernel.Capacity()(i, j) - kernel.Capacity()(j, i));
|
||||
ks = std::hypot(
|
||||
ks, kernel.Conductivity()(i, j) - kernel.Conductivity()(j, i));
|
||||
row += kernel.Conductivity()(i, j);
|
||||
total += kernel.Capacity()(i, j);
|
||||
}
|
||||
null = std::hypot(null, row);
|
||||
}
|
||||
EXPECT_LE(cs, 2 * ec);
|
||||
EXPECT_LE(ks, 2 * ek);
|
||||
EXPECT_LE(null, std::sqrt(20.) * ek);
|
||||
EXPECT_LE(std::abs(total - 2), 20 * ec);
|
||||
EXPECT_EQ(kernel.SourceId().source_label_text, "0019");
|
||||
EXPECT_EQ(kernel.NodeIndices(),
|
||||
(std::array<fesa::EntityIndex, 4>{{7, 2, 9, 4}}));
|
||||
}
|
||||
|
||||
TEST(ThermalShell, CapacityIsPositiveAndConductionHasExactlyOneNullMode) {
|
||||
const auto created = Kernel();
|
||||
ASSERT_TRUE(created.HasValue());
|
||||
const auto exact = Rational(2);
|
||||
double backward = 0;
|
||||
const auto capacity = EigenExtrema(created.Value().Capacity(), backward);
|
||||
EXPECT_GT(capacity.first, Gamma(4096) * Norm(exact.pc) + backward);
|
||||
const auto conduction =
|
||||
EigenExtrema(created.Value().Conductivity(), backward);
|
||||
EXPECT_LE(std::abs(conduction.first),
|
||||
Gamma(4096) * Norm(exact.pk) + backward);
|
||||
// Helmert columns form an independent orthonormal basis of 1-perp.
|
||||
Matrix basis{20, 19};
|
||||
for (std::size_t j = 0; j < 19; ++j) {
|
||||
const double norm = std::sqrt(static_cast<double>((j + 1) * (j + 2)));
|
||||
for (std::size_t i = 0; i <= j; ++i) basis(i, j) = 1 / norm;
|
||||
basis(j + 1, j) = -static_cast<double>(j + 1) / norm;
|
||||
}
|
||||
Matrix restricted{19, 19};
|
||||
for (std::size_t i = 0; i < 19; ++i)
|
||||
for (std::size_t j = 0; j < 19; ++j)
|
||||
for (std::size_t p = 0; p < 20; ++p)
|
||||
for (std::size_t q = 0; q < 20; ++q)
|
||||
restricted(i, j) +=
|
||||
basis(p, i) * created.Value().Conductivity()(p, q) * basis(q, j);
|
||||
const auto positive = EigenExtrema(restricted, backward);
|
||||
const double projection_budget = Gamma(8192) * Norm(exact.pk) * 20;
|
||||
EXPECT_GT(positive.first,
|
||||
Gamma(4096) * Norm(exact.pk) + backward + projection_budget);
|
||||
}
|
||||
|
||||
TEST(ThermalShell,
|
||||
Direct54MatchesSeparatedOperatorsOnActualThreeDimensionalPatches) {
|
||||
const std::array<Patch, 5> patches{
|
||||
{kSquare,
|
||||
{{{0, 0, 0}, {2, 0, 0}, {1.5, 1, 0}, {0, 1, 0}}},
|
||||
{{{-1, -1, .1}, {1, -1, -.1}, {1, 1, .1}, {-1, 1, -.1}}},
|
||||
{{{-1, -1, .5}, {1, -1, -.5}, {1, 1, .5}, {-1, 1, -.5}}},
|
||||
{{{-1, -1, 2}, {1, -1, -2}, {1, 1, 2}, {-1, 1, -2}}}}};
|
||||
for (const auto& patch : patches) {
|
||||
const auto created = Kernel(patch);
|
||||
ASSERT_TRUE(created.HasValue());
|
||||
const auto direct = Direct54(patch, 2);
|
||||
const auto& kernel = created.Value();
|
||||
// General-geometry separation error is the propagated interval enclosure;
|
||||
// direct 54's independent arithmetic has the prescribed gamma8192 budget.
|
||||
EXPECT_LE(Difference(kernel.Capacity(), direct.c),
|
||||
Norm(kernel.CapacityError()) + Gamma(8192) * Norm(direct.pc));
|
||||
EXPECT_LE(Difference(kernel.Conductivity(), direct.k),
|
||||
Norm(kernel.ConductivityError()) + Gamma(8192) * Norm(direct.pk));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ThermalShell, ThicknessScalingPreservesBothConductionTermsAndCapacity) {
|
||||
for (double h : {.5, 1., 2.}) {
|
||||
const auto created = Kernel(kSquare, h);
|
||||
ASSERT_TRUE(created.HasValue());
|
||||
const auto exact = Rational(h);
|
||||
EXPECT_LE(Difference(created.Value().Capacity(), exact.c),
|
||||
Gamma(4096) * Norm(exact.pc));
|
||||
EXPECT_LE(Difference(created.Value().Conductivity(), exact.k),
|
||||
Gamma(4096) * Norm(exact.pk));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ThermalShell, AffineLinearFluxFramesAndSeparateMidsurfaceTraces) {
|
||||
const auto created = Kernel();
|
||||
ASSERT_TRUE(created.HasValue());
|
||||
std::array<double, 20> temperature{};
|
||||
for (std::size_t i = 0; i < 4; ++i)
|
||||
for (std::size_t a = 0; a < 5; ++a)
|
||||
temperature[5 * i + a] =
|
||||
2 + 3 * kSquare[i].X() - 2 * kSquare[i].Y() + 4 * (-1 + .5 * a);
|
||||
const auto recovery = created.Value().Recover(temperature);
|
||||
ASSERT_TRUE(recovery.HasValue());
|
||||
const auto linear_scale = Flux(kSquare, 2, temperature);
|
||||
for (std::size_t row = 0; row < 24; ++row)
|
||||
EXPECT_LE(
|
||||
(recovery.Value().global_flux[row] - Vector3{-3, 2, -4}).Norm(),
|
||||
Gamma(2048) * (linear_scale.scale[row] + Vector3{3, -2, 4}.Norm()));
|
||||
for (const auto& frame : recovery.Value().frames) {
|
||||
for (std::size_t i = 0; i < 3; ++i)
|
||||
for (std::size_t j = 0; j < 3; ++j)
|
||||
EXPECT_NEAR(frame[i].Dot(frame[j]), i == j ? 1. : 0., Gamma(2048));
|
||||
EXPECT_NEAR(frame[0].Cross(frame[1]).Dot(frame[2]), 1., Gamma(2048));
|
||||
}
|
||||
for (std::size_t i = 0; i < 4; ++i)
|
||||
for (std::size_t a = 0; a < 5; ++a)
|
||||
temperature[5 * i + a] = a < 3 ? 0. : static_cast<double>(a - 2);
|
||||
const auto traces = created.Value().Recover(temperature);
|
||||
ASSERT_TRUE(traces.HasValue());
|
||||
for (std::size_t ip = 0; ip < 4; ++ip) {
|
||||
EXPECT_EQ(traces.Value().global_flux[6 * ip + 2].Z(), 0.);
|
||||
EXPECT_NEAR(traces.Value().global_flux[6 * ip + 3].Z(), -2.,
|
||||
Gamma(2048) * 2);
|
||||
}
|
||||
temperature.fill(2);
|
||||
const auto constant = created.Value().Recover(temperature);
|
||||
ASSERT_TRUE(constant.HasValue());
|
||||
const auto constant_scale = Flux(kSquare, 2, temperature);
|
||||
for (std::size_t row = 0; row < 24; ++row)
|
||||
EXPECT_LE(constant.Value().global_flux[row].Norm(),
|
||||
Gamma(2048) * constant_scale.scale[row]);
|
||||
}
|
||||
|
||||
TEST(ThermalShell, TranslationRotationAndReversedNormalPreservePhysicalFlux) {
|
||||
const auto base = Kernel();
|
||||
ASSERT_TRUE(base.HasValue());
|
||||
Patch translated = kSquare, rotated = kSquare, reversed{};
|
||||
const auto rotate = [](Vector3 p) { return Vector3{p.Z(), p.X(), p.Y()}; };
|
||||
for (std::size_t i = 0; i < 4; ++i) {
|
||||
translated[i] = kSquare[i] + Vector3{1048576, -1048576, 1048576};
|
||||
rotated[i] = rotate(kSquare[i]);
|
||||
reversed[i] = kSquare[(4 - i) % 4];
|
||||
}
|
||||
const auto moved = Kernel(translated), turned = Kernel(rotated),
|
||||
flipped = Kernel(reversed);
|
||||
ASSERT_TRUE(moved.HasValue());
|
||||
ASSERT_TRUE(turned.HasValue());
|
||||
ASSERT_TRUE(flipped.HasValue());
|
||||
EXPECT_LE(
|
||||
Difference(base.Value().Capacity(), moved.Value().Capacity()),
|
||||
Norm(base.Value().CapacityError()) + Norm(moved.Value().CapacityError()));
|
||||
EXPECT_LE(
|
||||
Difference(base.Value().Conductivity(), turned.Value().Conductivity()),
|
||||
Norm(base.Value().ConductivityError()) +
|
||||
Norm(turned.Value().ConductivityError()));
|
||||
std::array<double, 20> temperature{}, reverse_temperature{};
|
||||
for (std::size_t i = 0; i < 4; ++i)
|
||||
for (std::size_t a = 0; a < 5; ++a) {
|
||||
temperature[5 * i + a] =
|
||||
2 + 3 * kSquare[i].X() - 2 * kSquare[i].Y() + 4 * (-1 + .5 * a);
|
||||
reverse_temperature[5 * i + a] =
|
||||
2 + 3 * reversed[i].X() - 2 * reversed[i].Y() - 4 * (-1 + .5 * a);
|
||||
}
|
||||
const auto flux = turned.Value().Recover(temperature),
|
||||
reverse = flipped.Value().Recover(reverse_temperature);
|
||||
ASSERT_TRUE(flux.HasValue());
|
||||
ASSERT_TRUE(reverse.HasValue());
|
||||
const auto turn_scale = Flux(rotated, 2, temperature);
|
||||
const auto reverse_scale = Flux(reversed, 2, reverse_temperature);
|
||||
for (std::size_t row = 0; row < 24; ++row) {
|
||||
EXPECT_LE((flux.Value().global_flux[row] - rotate({-3, 2, -4})).Norm(),
|
||||
Gamma(2048) * (turn_scale.scale[row] + Vector3{3, -2, 4}.Norm()));
|
||||
EXPECT_LE(
|
||||
(reverse.Value().global_flux[row] - Vector3{-3, 2, -4}).Norm(),
|
||||
Gamma(2048) * (reverse_scale.scale[row] + Vector3{3, -2, 4}.Norm()));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ThermalShell, CheckedCreationAndRecoveryRetainSourceAndRejectNonfinite) {
|
||||
for (double bad : {0., -1., std::numeric_limits<double>::infinity(),
|
||||
std::numeric_limits<double>::quiet_NaN()}) {
|
||||
EXPECT_FALSE(Kernel(kSquare, bad).HasValue());
|
||||
EXPECT_FALSE(Kernel(kSquare, 2, bad).HasValue());
|
||||
}
|
||||
const auto created = Kernel();
|
||||
ASSERT_TRUE(created.HasValue());
|
||||
std::array<double, 20> temperature{};
|
||||
temperature[3] = std::numeric_limits<double>::quiet_NaN();
|
||||
const auto bad = created.Value().Recover(temperature);
|
||||
ASSERT_FALSE(bad.HasValue());
|
||||
EXPECT_EQ(bad.GetStatus().Diagnostics()[0].entity_identity, "patch:0019");
|
||||
EXPECT_EQ(bad.GetStatus().Diagnostics()[0].location.line, 42U);
|
||||
temperature.fill(std::numeric_limits<double>::max());
|
||||
temperature[0] = -temperature[0];
|
||||
EXPECT_FALSE(created.Value().Recover(temperature).HasValue());
|
||||
}
|
||||
|
||||
TEST(ThermalShell, GeneralWarpedFluxUsesActualReciprocalTangentsAndTraceOrder) {
|
||||
const Patch saddle{{{-1, -1, 2}, {1, -1, -2}, {1, 1, 2}, {-1, 1, -2}}};
|
||||
const auto created = Kernel(saddle, 1);
|
||||
ASSERT_TRUE(created.HasValue());
|
||||
std::array<double, 20> temperature{};
|
||||
for (std::size_t p = 0; p < 20; ++p)
|
||||
temperature[p] = static_cast<double>((p * 7) % 11) - 4;
|
||||
const auto actual = created.Value().Recover(temperature);
|
||||
ASSERT_TRUE(actual.HasValue());
|
||||
const auto expected = Flux(saddle, 1, temperature);
|
||||
for (std::size_t row = 0; row < 24; ++row)
|
||||
EXPECT_LE((actual.Value().global_flux[row] - expected.value[row]).Norm(),
|
||||
2 * Gamma(2048) * expected.scale[row]);
|
||||
// Local normals vary around this saddle; recovery must retain those frames.
|
||||
EXPECT_GT((actual.Value().frames[0][2] - actual.Value().frames[1][2]).Norm(),
|
||||
1.);
|
||||
}
|
||||
|
||||
TEST(ThermalShell, PowerOfTwoCoordinateScalingPreservesDimensionedOperators) {
|
||||
for (int exponent : {-200, 200}) {
|
||||
Patch patch = kSquare;
|
||||
const double scale = std::ldexp(1., exponent);
|
||||
for (auto& point : patch) point = scale * point;
|
||||
const auto created = Kernel(patch, 2);
|
||||
ASSERT_TRUE(created.HasValue());
|
||||
const auto unit = Rational(2);
|
||||
const auto very_thick = Rational(2 / scale);
|
||||
// Area multiplies C by s^2; surface conduction is invariant, while the
|
||||
// normal term multiplies by s^2. Equivalent expression uses h/s and k*s.
|
||||
Matrix c{20, 20}, k{20, 20};
|
||||
for (std::size_t p = 0; p < 20; ++p)
|
||||
for (std::size_t q = 0; q < 20; ++q) {
|
||||
c(p, q) = std::ldexp(unit.c(p, q), 2 * exponent);
|
||||
k(p, q) = scale * very_thick.k(p, q);
|
||||
}
|
||||
EXPECT_LE(Difference(created.Value().Capacity(), c),
|
||||
Norm(created.Value().CapacityError()) + Gamma(4096) * Norm(c));
|
||||
EXPECT_LE(
|
||||
Difference(created.Value().Conductivity(), k),
|
||||
Norm(created.Value().ConductivityError()) + Gamma(4096) * Norm(k));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ThermalShell, RejectsUnrepresentablePositivePropertiesWithoutClamping) {
|
||||
const auto underflow =
|
||||
fesa::ThermalShell::Create({"patch", 19, "0019"}, {{0, 1, 2, 3}}, kSquare,
|
||||
1, std::numeric_limits<double>::denorm_min(),
|
||||
std::numeric_limits<double>::denorm_min(), 1);
|
||||
EXPECT_FALSE(underflow.HasValue());
|
||||
const auto overflow =
|
||||
fesa::ThermalShell::Create({"patch", 19, "0019"}, {{0, 1, 2, 3}}, kSquare,
|
||||
1, std::numeric_limits<double>::max(), 2, 1);
|
||||
EXPECT_FALSE(overflow.HasValue());
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
#include "fesa/elements/thermal_shell.h"
|
||||
#include "fesa/model/domain.h"
|
||||
|
||||
namespace {
|
||||
using fesa::Vector3;
|
||||
using Patch = std::array<Vector3, 4>;
|
||||
const Patch kSquare{{{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}}};
|
||||
|
||||
fesa::Result<fesa::ThermalShell> Kernel(const Patch& patch) {
|
||||
return fesa::ThermalShell::Create({"part", 27, "0027"}, {{0, 1, 2, 3}}, patch,
|
||||
1., 1., 1., 1., {"mesh.inp", 17});
|
||||
}
|
||||
|
||||
fesa::ModelDefinition Mesh(
|
||||
const std::vector<Vector3>& nodes,
|
||||
const std::vector<std::array<fesa::EntityIndex, 4>>& elements) {
|
||||
fesa::ModelDefinition model{};
|
||||
for (std::size_t i = 0; i < nodes.size(); ++i) {
|
||||
model.nodes.push_back(
|
||||
{{"part", static_cast<std::int64_t>(i + 1), std::to_string(i + 1)},
|
||||
nodes[i].Components(),
|
||||
{"mesh.inp", i + 1}});
|
||||
}
|
||||
model.thermal_materials.push_back({{"part", 1, "1"}, "heat", 1, 1, 1, {}});
|
||||
model.thermal_sections.push_back({{"part", 1, "1"}, "section", 1, 0, {}});
|
||||
for (std::size_t i = 0; i < elements.size(); ++i) {
|
||||
model.thermal_elements.push_back(
|
||||
{{"part", static_cast<std::int64_t>(i + 1), std::to_string(i + 1)},
|
||||
elements[i],
|
||||
0,
|
||||
{"mesh.inp", i + 20}});
|
||||
}
|
||||
model.thermal_step = fesa::ThermalStepDefinition{"heat", .1, 1., 10};
|
||||
return model;
|
||||
}
|
||||
|
||||
bool Accepted(fesa::ModelDefinition model) {
|
||||
return fesa::Domain::Create(std::move(model)).HasValue();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(ThermalShellGeometry, CertifiesAffineDistortedSaddleAndSlenderPatches) {
|
||||
EXPECT_TRUE(Kernel(kSquare).HasValue());
|
||||
EXPECT_TRUE(
|
||||
Kernel({{{0, 0, 0}, {2, 0, 0}, {3, 1, 0}, {1, 1, 0}}}).HasValue());
|
||||
EXPECT_TRUE(
|
||||
Kernel({{{0, 0, 0}, {2, 0, 0}, {1.5, 1, 0}, {0, 1, 0}}}).HasValue());
|
||||
EXPECT_TRUE(Kernel({{{0, 0, 0},
|
||||
{1, 0, 0},
|
||||
{1, std::ldexp(1., -40), 0},
|
||||
{0, std::ldexp(1., -40), 0}}})
|
||||
.HasValue());
|
||||
for (double a : {.1, .5, 2.}) {
|
||||
EXPECT_TRUE(
|
||||
Kernel({{{-1, -1, a}, {1, -1, -a}, {1, 1, a}, {-1, 1, -a}}}).HasValue())
|
||||
<< a;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ThermalShellGeometry, PowerOfTwoScalingTranslationAndRotation) {
|
||||
for (int exponent : {-200, 0, 200}) {
|
||||
Patch patch = kSquare;
|
||||
for (auto& point : patch) point = std::ldexp(1., exponent) * point;
|
||||
EXPECT_TRUE(Kernel(patch).HasValue()) << exponent;
|
||||
}
|
||||
Patch translated = kSquare;
|
||||
Patch rotated = kSquare;
|
||||
for (std::size_t i = 0; i < 4; ++i) {
|
||||
translated[i] = translated[i] + Vector3{1048576., -1048576., 1048576.};
|
||||
rotated[i] = Vector3{.6 * kSquare[i].X() - .8 * kSquare[i].Y(),
|
||||
.8 * kSquare[i].X() + .6 * kSquare[i].Y(), 0};
|
||||
}
|
||||
EXPECT_TRUE(Kernel(translated).HasValue());
|
||||
EXPECT_TRUE(Kernel(rotated).HasValue());
|
||||
}
|
||||
|
||||
TEST(ThermalShellGeometry,
|
||||
RejectsWholePatchSingularityDespiteNonzeroGaussPoints) {
|
||||
Patch patch{};
|
||||
const std::array<double, 4> xi{{-1, 1, 1, -1}};
|
||||
const std::array<double, 4> eta{{-1, -1, 1, 1}};
|
||||
for (std::size_t i = 0; i < 4; ++i)
|
||||
patch[i] = {xi[i], (xi[i] - .25) * eta[i], 0};
|
||||
const auto result = Kernel(patch);
|
||||
ASSERT_FALSE(result.HasValue());
|
||||
EXPECT_EQ(result.GetStatus().Diagnostics()[0].entity_identity, "part:0027");
|
||||
EXPECT_EQ(result.GetStatus().Diagnostics()[0].location.line, 17U);
|
||||
for (double q : {-std::sqrt(.6), 0., std::sqrt(.6)})
|
||||
EXPECT_GT(std::abs(q - .25), 0.);
|
||||
}
|
||||
|
||||
TEST(ThermalShellGeometry, RejectsInvalidAndDoesNotRepairUncertainGeometry) {
|
||||
EXPECT_FALSE(
|
||||
Kernel({{{0, 0, 0}, {1, 1, 0}, {1, 0, 0}, {0, 1, 0}}}).HasValue());
|
||||
EXPECT_FALSE(
|
||||
Kernel({{{0, 0, 0}, {1, 0, 0}, {2, 0, 0}, {3, 0, 0}}}).HasValue());
|
||||
Patch repeated = kSquare;
|
||||
repeated[2] = repeated[1];
|
||||
EXPECT_FALSE(Kernel(repeated).HasValue());
|
||||
for (double bad : {std::numeric_limits<double>::infinity(),
|
||||
std::numeric_limits<double>::quiet_NaN()}) {
|
||||
Patch patch = kSquare;
|
||||
patch[0] = {bad, 0, 0};
|
||||
EXPECT_FALSE(Kernel(patch).HasValue());
|
||||
}
|
||||
const auto duplicate = fesa::ThermalShell::Create(
|
||||
{"part", 1, "1"}, {{0, 1, 1, 3}}, kSquare, 1, 1, 1, 1);
|
||||
EXPECT_FALSE(duplicate.HasValue());
|
||||
const double e = std::ldexp(1., -52);
|
||||
const auto uncertain =
|
||||
Kernel({{{-2, -2 - e, 0}, {0, -e, 0}, {2, 2 + e, 0}, {0, e, 0}}});
|
||||
if (!uncertain.HasValue())
|
||||
EXPECT_EQ(uncertain.GetStatus().Diagnostics()[0].code,
|
||||
"uncertain-thermal-shell-geometry");
|
||||
}
|
||||
|
||||
TEST(ThermalShellGeometry, DomainEnforcesOppositeEdgesAndOneVertexLink) {
|
||||
const std::vector<Vector3> nodes{{0, 0, 0}, {1, 0, 0}, {1, 1, 0},
|
||||
{0, 1, 0}, {2, 0, 0}, {2, 1, 0}};
|
||||
EXPECT_TRUE(Accepted(Mesh(nodes, {{{0, 1, 2, 3}}, {{1, 4, 5, 2}}})));
|
||||
EXPECT_FALSE(Accepted(Mesh(nodes, {{{0, 1, 2, 3}}, {{2, 5, 4, 1}}})));
|
||||
EXPECT_FALSE(Accepted(Mesh(nodes, {{{0, 1, 2, 3}}, {{0, 1, 2, 3}}})));
|
||||
EXPECT_FALSE(Accepted(Mesh({{0, 0, 0},
|
||||
{1, 0, 0},
|
||||
{1, 1, 0},
|
||||
{0, 1, 0},
|
||||
{-1, -1, 0},
|
||||
{0, -1, 0},
|
||||
{-1, 0, 0}},
|
||||
{{{0, 1, 2, 3}}, {{0, 6, 4, 5}}})));
|
||||
EXPECT_FALSE(
|
||||
Accepted(Mesh({{0, 0, 0},
|
||||
{1, 0, 0},
|
||||
{1, 1, 0},
|
||||
{0, 1, 0},
|
||||
{2, 0, 0},
|
||||
{2, 1, 0},
|
||||
{1, 0, 1},
|
||||
{1, 1, 1}},
|
||||
{{{0, 1, 2, 3}}, {{1, 4, 5, 2}}, {{1, 6, 7, 2}}})));
|
||||
}
|
||||
|
||||
TEST(ThermalShellGeometry, DomainAllowsFoldAndDisconnectedCoordinateContact) {
|
||||
auto folded =
|
||||
Mesh({{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}, {1, 0, 1}, {1, 1, 1}},
|
||||
{{{0, 1, 2, 3}}, {{1, 4, 5, 2}}});
|
||||
folded.thermal_materials.push_back({{"part", 2, "2"}, "heat2", 10, 1, 1, {}});
|
||||
folded.thermal_sections.push_back({{"part", 2, "2"}, "section2", .5, 1, {}});
|
||||
folded.thermal_elements[1].section_index = 1;
|
||||
EXPECT_TRUE(Accepted(std::move(folded)));
|
||||
const std::vector<Vector3> nodes{{0, 0, 0}, {2, 0, 0}, {2, 1, 0},
|
||||
{0, 1, 0}, {0, 0, 0}, {1, 0, 0},
|
||||
{1, -1, 0}, {0, -1, 0}};
|
||||
auto disconnected = Mesh(nodes, {{{0, 1, 2, 3}}, {{4, 5, 6, 7}}});
|
||||
EXPECT_TRUE(Accepted(disconnected));
|
||||
for (std::size_t i = 4; i < 8; ++i)
|
||||
disconnected.nodes[i].source_id.instance_name = "other";
|
||||
disconnected.thermal_elements[1].source_id.instance_name = "other";
|
||||
EXPECT_TRUE(Accepted(disconnected));
|
||||
}
|
||||
|
||||
TEST(ThermalShellGeometry,
|
||||
DomainRejectsSourceSharingPartialEdgeWithoutGlobalScan) {
|
||||
// The patches share the full 3->0 / 0->3 edge: connected vertex links remain
|
||||
// paths, while the additional 0->4 edge lies inside coarse 0->1.
|
||||
const std::vector<Vector3> nodes{{0, 0, 0}, {2, 0, 0}, {2, 1, 0},
|
||||
{0, 1, 0}, {1, 0, 0}, {1, 1, 1}};
|
||||
const auto model = Mesh(nodes, {{{0, 1, 2, 3}}, {{0, 3, 5, 4}}});
|
||||
const auto result = fesa::Domain::Create(model);
|
||||
ASSERT_FALSE(result.HasValue());
|
||||
EXPECT_EQ(result.GetStatus().Diagnostics()[0].code,
|
||||
"uncertain-thermal-shell-connection");
|
||||
auto invalid =
|
||||
Mesh({{0, 0, 0}, {1, 1, 0}, {1, 0, 0}, {0, 1, 0}}, {{{0, 1, 2, 3}}});
|
||||
EXPECT_FALSE(Accepted(invalid));
|
||||
}
|
||||
|
||||
TEST(ThermalShellGeometry, SearchesSeparatingDirectionsBeyondCenterAndCorners) {
|
||||
// Exact raw certificate s=(0,-3,6) has min(s.w)=9; center has zero margin.
|
||||
EXPECT_TRUE(
|
||||
Kernel({{{3, -9, -3}, {1, 3, -3}, {-1, 3, 3}, {-3, 3, 3}}}).HasValue());
|
||||
// Exact s=(1.5,2.5,1) gives min(s.w)=3.5, whereas center/corner candidates
|
||||
// cannot separate the normal image. Edge/interior minimization is needed.
|
||||
EXPECT_TRUE(
|
||||
Kernel({{{-1, 4, -4}, {-1, -2, 2}, {3, 2, 4}, {-1, -4, -2}}}).HasValue());
|
||||
}
|
||||
|
||||
TEST(ThermalShellGeometry,
|
||||
RejectsAliasedSourceIdentityAndAcceptsClosedVertexLink) {
|
||||
auto aliases =
|
||||
Mesh({{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}}, {{{0, 1, 2, 3}}});
|
||||
aliases.nodes[2].source_id = aliases.nodes[1].source_id;
|
||||
EXPECT_FALSE(Accepted(aliases));
|
||||
EXPECT_TRUE(Accepted(
|
||||
Mesh({{-1, -1, 0},
|
||||
{0, -1, 0},
|
||||
{1, -1, 0},
|
||||
{-1, 0, 0},
|
||||
{0, 0, 0},
|
||||
{1, 0, 0},
|
||||
{-1, 1, 0},
|
||||
{0, 1, 0},
|
||||
{1, 1, 0}},
|
||||
{{{0, 1, 4, 3}}, {{1, 2, 5, 4}}, {{3, 4, 7, 6}}, {{4, 5, 8, 7}}})));
|
||||
}
|
||||
Reference in New Issue
Block a user