Files
FESADev/tests/unit/model/thermal_shell_geometry_test.cpp

279 lines
11 KiB
C++

#include <gtest/gtest.h>
#include <array>
#include <cmath>
#include <limits>
#include <string>
#include "fesa/elements/thermal_shell.h"
#include "fesa/model/domain.h"
#include "elements/thermal_shell_geometry.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}}})));
}
namespace {
TEST(ThermalGeometryAction, EnclosesIndependentDyadicTangentialConstraints) {
using namespace fesa::thermal_internal;
// The rounded Gram matrix loses rank for delta=2^-40, while the physical
// tangents and their exterior determinant remain certified and nonsingular.
for (const int exponent : {-40,-27,-4,0}) {
const double delta = std::ldexp(1.,exponent);
Geometry geometry;
geometry.b = {1,1,0};
geometry.c = {1,1+delta,0};
for (std::size_t j=0; j<3; ++j) {
geometry.bi[j] = Interval{geometry.b[j]};
geometry.ci[j] = Interval{geometry.c[j]};
}
const auto evaluated = EvaluateSurface(geometry, 0, 0, Interval{0},
Interval{0}, {}, {});
ASSERT_TRUE(evaluated.HasValue()) << exponent;
const double dx[4]{-.25,.25,.25,-.25};
const double dy[4]{-.25,-.25,.25,.25};
for (std::size_t i=0; i<4; ++i) {
const double gy = (dy[i]-dx[i])/delta;
const double gx = dx[i]-gy;
const double oracle[3]{gx,gy,0};
for (std::size_t j=0; j<3; ++j) {
EXPECT_DOUBLE_EQ(evaluated.Value().gradient[i][j],oracle[j]);
const auto interval = evaluated.Value().gradient_interval[i][j];
EXPECT_LE(interval.lower,oracle[j]);
EXPECT_GE(interval.upper,oracle[j]);
}
}
}
}
TEST(ThermalGeometryAction, PreservesWholePatchAndScaledFiniteRejections) {
using namespace fesa::thermal_internal;
for (const Patch patch : {
kSquare,
Patch{{{3,-9,-3},{1,3,-3},{-1,3,3},{-3,3,3}}},
Patch{{{-1,4,-4},{-1,-2,2},{3,2,4},{-1,-4,-2}}}}) {
const auto geometry = BuildGeometry(patch, {}, {});
ASSERT_TRUE(geometry.HasValue());
for (const double xi : {-std::sqrt(.6),0.,std::sqrt(.6)})
for (const double eta : {-std::sqrt(.6),0.,std::sqrt(.6)})
EXPECT_TRUE(EvaluateSurface(geometry.Value(),xi,eta,
Interval{Down(xi),Up(xi)},Interval{Down(eta),Up(eta)},{},{}).HasValue());
}
const Patch folded{{{0,0,0},{1,1,0},{1,0,0},{0,1,0}}};
const auto rejected = BuildGeometry(folded,{"part",27,"0027"},{"mesh.inp",17});
ASSERT_FALSE(rejected.HasValue());
EXPECT_EQ(rejected.GetStatus().Diagnostics()[0].code,
"uncertain-thermal-shell-geometry");
EXPECT_EQ(rejected.GetStatus().Diagnostics()[0].entity_identity,"part:0027");
Geometry geometry;
geometry.b = {1,0,0}; geometry.c = {0,1,0};
geometry.bi[0] = Interval{1}; geometry.ci[1] = Interval{1};
geometry.length_exponent = -600;
const auto scaled = EvaluateSurface(geometry,0,0,Interval{0},Interval{0},
{"part",27,"0027"},{"mesh.inp",17});
ASSERT_FALSE(scaled.HasValue());
EXPECT_EQ(scaled.GetStatus().Diagnostics()[0].code,
"uncertain-thermal-shell-metric");
EXPECT_EQ(scaled.GetStatus().Diagnostics()[0].message,
"Metric inverse enclosure is not finite or excludes no zero divisor.");
}
} // namespace