#include "fesa/elements/thermal_shell.h" #include #include #include #include #include #include namespace { using fesa::Matrix; using fesa::Vector3; using Patch = std::array; 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 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 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 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 l{ {s * (s - 1) / 2, 1 - s * s, s * (s + 1) / 2}}; const std::array dl{{2 * s - 1, -4 * s, 2 * s + 1}}; std::array shape{}; std::array 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 value{}; std::array scale{}; }; FluxOracle Flux(const Patch& patch, double h, const std::array& 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 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(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 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(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(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{{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((j + 1) * (j + 2))); for (std::size_t i = 0; i <= j; ++i) basis(i, j) = 1 / norm; basis(j + 1, j) = -static_cast(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 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 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(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 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::infinity(), std::numeric_limits::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 temperature{}; temperature[3] = std::numeric_limits::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::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 temperature{}; for (std::size_t p = 0; p < 20; ++p) temperature[p] = static_cast((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::denorm_min(), std::numeric_limits::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::max(), 2, 1); EXPECT_FALSE(overflow.HasValue()); }